From 91cbf1568b15039a188f1e9bffbaf5a1b01ff459 Mon Sep 17 00:00:00 2001 From: architdhamija Date: Thu, 10 Sep 2026 14:30:34 +0530 Subject: [PATCH 1/2] feat(hub): add design-partner request form on Overview --- .mex/patterns/secure-local-project-hub.md | 6 +- package-lock.json | 11 - packages/hub-web/src/env.d.ts | 1 + .../hub-web/src/lib/team-access-lead.test.ts | 183 +++++++++ packages/hub-web/src/lib/team-access-lead.ts | 201 ++++++++++ packages/hub-web/src/pages/HomeOverview.tsx | 45 +-- .../hub-web/src/pages/TeamAccessCard.test.tsx | 191 +++++++++ packages/hub-web/src/pages/TeamAccessCard.tsx | 371 ++++++++++++++++++ packages/hub-web/src/styles/home.module.css | 17 +- .../hub-web/src/styles/team-access.module.css | 204 ++++++++++ packages/hub-web/src/test/setup.ts | 5 + src/hub/app.ts | 2 +- 12 files changed, 1175 insertions(+), 62 deletions(-) create mode 100644 packages/hub-web/src/lib/team-access-lead.test.ts create mode 100644 packages/hub-web/src/lib/team-access-lead.ts create mode 100644 packages/hub-web/src/pages/TeamAccessCard.test.tsx create mode 100644 packages/hub-web/src/pages/TeamAccessCard.tsx create mode 100644 packages/hub-web/src/styles/team-access.module.css diff --git a/.mex/patterns/secure-local-project-hub.md b/.mex/patterns/secure-local-project-hub.md index 7accbda3..93d1a7a1 100644 --- a/.mex/patterns/secure-local-project-hub.md +++ b/.mex/patterns/secure-local-project-hub.md @@ -11,7 +11,7 @@ edges: condition: "when persisting a Hub job or migrating team.db" - target: "context/architecture.md" condition: "when wiring a real Graph or Wiki adapter" -last_updated: 2026-09-08 +last_updated: 2026-09-10 mex: id: mx_01M1M0CJQ2BSV71G1C7TXZD9RH type: pattern @@ -95,6 +95,10 @@ preview/apply services. trace, or origin fields. Hub read models must omit those fields and bound subject/message previews before response validation. Schema-v2 Activity workflow/custom origin and optional labels use their closed projections. +- The Overview team-access card POSTs an allowlisted name/email payload (and an + optional follow-up) from the browser to `https://api.web3forms.com`. CSP + `connect-src` names that host only; Hub must not proxy repo, path, graph, or + machine data, and page load must not initiate that request. - New read surfaces need successful-job cache invalidation as well as their initial query. Context's graph, selected record, and compact code queries all diff --git a/package-lock.json b/package-lock.json index 5d896bd0..e6aed83a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4095,7 +4095,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -4117,7 +4116,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -4139,7 +4137,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -4161,7 +4158,6 @@ "os": [ "freebsd" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -4183,7 +4179,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -4205,7 +4200,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -4227,7 +4221,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -4249,7 +4242,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -4271,7 +4263,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -4293,7 +4284,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -4315,7 +4305,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, diff --git a/packages/hub-web/src/env.d.ts b/packages/hub-web/src/env.d.ts index 7d82f4a1..bf0c9f25 100644 --- a/packages/hub-web/src/env.d.ts +++ b/packages/hub-web/src/env.d.ts @@ -3,6 +3,7 @@ interface ImportMetaEnv { readonly DEV: boolean; readonly PROD: boolean; + readonly VITE_WEB3FORMS_ACCESS_KEY?: string; } interface ImportMeta { diff --git a/packages/hub-web/src/lib/team-access-lead.test.ts b/packages/hub-web/src/lib/team-access-lead.test.ts new file mode 100644 index 00000000..359caf32 --- /dev/null +++ b/packages/hub-web/src/lib/team-access-lead.test.ts @@ -0,0 +1,183 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + TEAM_ACCESS_FOLLOW_UP_SUBJECT, + TEAM_ACCESS_SOURCE, + TEAM_ACCESS_STORAGE_KEY, + TEAM_ACCESS_SUBJECT, + TEAM_ACCESS_SUBMIT_ERROR, + WEB3FORMS_SUBMIT_URL, + __setWeb3FormsAccessKeyForTests, + buildTeamAccessContactPayload, + buildTeamAccessFollowUpPayload, + readTeamAccessState, + submitTeamAccessPayload, + validateTeamAccessContact, + writeTeamAccessState, +} from "./team-access-lead"; + +afterEach(() => { + __setWeb3FormsAccessKeyForTests(null); + window.localStorage.removeItem(TEAM_ACCESS_STORAGE_KEY); +}); + +describe("team-access lead payloads", () => { + it("builds a closed contact payload without repo or machine fields", () => { + __setWeb3FormsAccessKeyForTests("public-test-key"); + const payload = buildTeamAccessContactPayload({ + name: " Ada Lovelace ", + email: " ada@example.com ", + }); + + expect(payload).toEqual({ + access_key: "public-test-key", + name: "Ada Lovelace", + email: "ada@example.com", + subject: TEAM_ACCESS_SUBJECT, + from_name: "mex Hub", + source: TEAM_ACCESS_SOURCE, + }); + expect(Object.keys(payload).sort()).toEqual([ + "access_key", + "email", + "from_name", + "name", + "source", + "subject", + ]); + }); + + it("omits empty optional follow-up fields and keeps the same contact identity", () => { + __setWeb3FormsAccessKeyForTests("public-test-key"); + const payload = buildTeamAccessFollowUpPayload({ + name: "Ada Lovelace", + email: "ada@example.com", + company: " ", + teamSize: "", + foundMex: "", + installReason: "", + repoKind: "", + othersUseAgents: "", + need: "", + missing: "", + }); + + expect(payload).toEqual({ + access_key: "public-test-key", + name: "Ada Lovelace", + email: "ada@example.com", + subject: TEAM_ACCESS_FOLLOW_UP_SUBJECT, + from_name: "mex Hub", + source: TEAM_ACCESS_SOURCE, + }); + expect(payload).not.toHaveProperty("company"); + expect(payload).not.toHaveProperty("i_want"); + expect(payload).not.toHaveProperty("repo"); + expect(payload).not.toHaveProperty("path"); + }); + + it("includes only allowlisted optional answers", () => { + __setWeb3FormsAccessKeyForTests("public-test-key"); + const payload = buildTeamAccessFollowUpPayload({ + name: "Ada Lovelace", + email: "ada@example.com", + company: "Analytical Engines", + teamSize: "2–10", + foundMex: "GitHub", + installReason: "Agent memory", + repoKind: "Work", + othersUseAgents: "Yes", + need: "Shared team memory", + missing: "Shared follow-up", + }); + + expect(payload).toMatchObject({ + company: "Analytical Engines", + team_size: "2–10", + found_mex: "GitHub", + install_reason: "Agent memory", + repo_kind: "Work", + others_use_agents: "Yes", + i_need: "Shared team memory", + whats_missing: "Shared follow-up", + }); + expect(Object.keys(payload).sort()).toEqual([ + "access_key", + "company", + "email", + "found_mex", + "from_name", + "i_need", + "install_reason", + "name", + "others_use_agents", + "repo_kind", + "source", + "subject", + "team_size", + "whats_missing", + ]); + expect(payload).not.toHaveProperty("repo"); + expect(payload).not.toHaveProperty("path"); + }); + + it("rejects blank or malformed contact details before submit", () => { + expect(validateTeamAccessContact("", "ada@example.com")).toEqual({ name: "Enter your name." }); + expect(validateTeamAccessContact("Ada", "")).toEqual({ email: "Enter your email." }); + expect(validateTeamAccessContact("Ada", "not-an-email")).toEqual({ email: "Enter a valid email." }); + expect(validateTeamAccessContact("Ada", "ada@example.com")).toEqual({}); + }); +}); + +describe("team-access Web3Forms submit", () => { + it("posts JSON to Web3Forms and requires a success response", async () => { + __setWeb3FormsAccessKeyForTests("public-test-key"); + const fetchImpl = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ success: true }), + }); + const payload = buildTeamAccessContactPayload({ name: "Ada", email: "ada@example.com" }); + const result = await submitTeamAccessPayload(payload, fetchImpl as unknown as typeof fetch); + + expect(result).toEqual({ ok: true }); + expect(fetchImpl).toHaveBeenCalledOnce(); + expect(fetchImpl.mock.calls[0]?.[0]).toBe(WEB3FORMS_SUBMIT_URL); + expect(fetchImpl.mock.calls[0]?.[1]).toMatchObject({ + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + }, + }); + expect(JSON.parse(String(fetchImpl.mock.calls[0]?.[1]?.body))).toEqual(payload); + }); + + it("stays failed when Web3Forms does not accept the payload", async () => { + const fetchImpl = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ success: false, message: "invalid" }), + }); + const result = await submitTeamAccessPayload( + { access_key: "public-test-key", name: "Ada", email: "ada@example.com" }, + fetchImpl as unknown as typeof fetch, + ); + expect(result).toEqual({ ok: false, message: TEAM_ACCESS_SUBMIT_ERROR }); + }); + + it("does not call the network when the public access key is missing", async () => { + const fetchImpl = vi.fn(); + const result = await submitTeamAccessPayload( + { access_key: "", name: "Ada", email: "ada@example.com" }, + fetchImpl as unknown as typeof fetch, + ); + expect(result).toEqual({ ok: false, message: TEAM_ACCESS_SUBMIT_ERROR }); + expect(fetchImpl).not.toHaveBeenCalled(); + }); +}); + +describe("team-access local state", () => { + it("remembers that this Hub checkout already sent contact details", () => { + expect(readTeamAccessState()).toBeNull(); + writeTeamAccessState({ contactSent: true }); + expect(readTeamAccessState()).toEqual({ contactSent: true }); + }); +}); diff --git a/packages/hub-web/src/lib/team-access-lead.ts b/packages/hub-web/src/lib/team-access-lead.ts new file mode 100644 index 00000000..2ced2e5c --- /dev/null +++ b/packages/hub-web/src/lib/team-access-lead.ts @@ -0,0 +1,201 @@ +/** + * Checkout-local team-access lead capture. The Hub posts only the fields below + * to Web3Forms from the browser; it never attaches repo, path, graph, or machine data. + * + * Configure the public access key here, or override at Hub build time with + * VITE_WEB3FORMS_ACCESS_KEY. Web3Forms access keys are designed to ship in + * frontend bundles; do not put SMTP passwords or other private secrets here. + */ +export const WEB3FORMS_ACCESS_KEY = "20549db8-9c62-4da9-920a-f70a08c8ee44"; + +export const WEB3FORMS_SUBMIT_URL = "https://api.web3forms.com/submit"; +export const TEAM_ACCESS_STORAGE_KEY = "mex.hub.team-access.v1"; +export const TEAM_ACCESS_SUBJECT = "mex Hub team access"; +export const TEAM_ACCESS_FOLLOW_UP_SUBJECT = "mex Hub team access follow-up"; +export const TEAM_ACCESS_FROM_NAME = "mex Hub"; +export const TEAM_ACCESS_SOURCE = "mex-hub"; +export const TEAM_ACCESS_SUBMIT_ERROR = "Could not send your request. Try again."; + +export const TEAM_ACCESS_TEAM_SIZES = ["Just me", "2–10", "11–50", "50+"] as const; +export const TEAM_ACCESS_FOUND_MEX = ["GitHub", "X", "friend", "community", "search", "other"] as const; +export const TEAM_ACCESS_INSTALL_REASONS = ["Agent memory", "team consistency", "token cost", "curiosity"] as const; +export const TEAM_ACCESS_REPO_KINDS = ["Work", "personal"] as const; +export const TEAM_ACCESS_OTHERS_USE_AGENTS = ["Yes", "No"] as const; +export const TEAM_ACCESS_NEEDS = ["Local only is fine", "Shared team memory", "Not sure"] as const; + +export interface TeamAccessContact { + name: string; + email: string; +} + +export interface TeamAccessFollowUp extends TeamAccessContact { + company: string; + teamSize: string; + foundMex: string; + installReason: string; + repoKind: string; + othersUseAgents: string; + need: string; + missing: string; +} + +export interface TeamAccessLocalState { + contactSent: true; +} + +const NAME_MAX = 200; +const EMAIL_MAX = 320; +const COMPANY_MAX = 200; +const MISSING_MAX = 240; + +let accessKeyOverride: string | null = null; + +/** Test seam: inject a public access key without touching import.meta.env. */ +export function __setWeb3FormsAccessKeyForTests(value: string | null): void { + accessKeyOverride = value; +} + +export function getWeb3FormsAccessKey(): string { + if (accessKeyOverride !== null) return accessKeyOverride; + const fromEnv = import.meta.env.VITE_WEB3FORMS_ACCESS_KEY; + if (typeof fromEnv === "string" && fromEnv.trim() !== "") return fromEnv.trim(); + return WEB3FORMS_ACCESS_KEY.trim(); +} + +export function boundName(value: string): string { + return value.trim().slice(0, NAME_MAX); +} + +export function boundEmail(value: string): string { + return value.trim().slice(0, EMAIL_MAX); +} + +export function boundCompany(value: string): string { + return value.trim().slice(0, COMPANY_MAX); +} + +export function boundMissing(value: string): string { + return value.trim().slice(0, MISSING_MAX); +} + +function includeAllowed( + payload: Record, + key: string, + value: string, + allowed: readonly string[], +): void { + if (allowed.includes(value)) payload[key] = value; +} + +export function validateTeamAccessContact(name: string, email: string): { + name?: string; + email?: string; +} { + const errors: { name?: string; email?: string } = {}; + if (boundName(name) === "") errors.name = "Enter your name."; + const trimmedEmail = boundEmail(email); + if (trimmedEmail === "") errors.email = "Enter your email."; + else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(trimmedEmail)) errors.email = "Enter a valid email."; + return errors; +} + +export function buildTeamAccessContactPayload(contact: TeamAccessContact, accessKey = getWeb3FormsAccessKey()) { + return { + access_key: accessKey, + name: boundName(contact.name), + email: boundEmail(contact.email), + subject: TEAM_ACCESS_SUBJECT, + from_name: TEAM_ACCESS_FROM_NAME, + source: TEAM_ACCESS_SOURCE, + }; +} + +export function buildTeamAccessFollowUpPayload(details: TeamAccessFollowUp, accessKey = getWeb3FormsAccessKey()) { + const payload: Record = { + access_key: accessKey, + name: boundName(details.name), + email: boundEmail(details.email), + subject: TEAM_ACCESS_FOLLOW_UP_SUBJECT, + from_name: TEAM_ACCESS_FROM_NAME, + source: TEAM_ACCESS_SOURCE, + }; + const company = boundCompany(details.company); + if (company !== "") payload.company = company; + includeAllowed(payload, "team_size", details.teamSize, TEAM_ACCESS_TEAM_SIZES); + includeAllowed(payload, "found_mex", details.foundMex, TEAM_ACCESS_FOUND_MEX); + includeAllowed(payload, "install_reason", details.installReason, TEAM_ACCESS_INSTALL_REASONS); + includeAllowed(payload, "repo_kind", details.repoKind, TEAM_ACCESS_REPO_KINDS); + includeAllowed(payload, "others_use_agents", details.othersUseAgents, TEAM_ACCESS_OTHERS_USE_AGENTS); + includeAllowed(payload, "i_need", details.need, TEAM_ACCESS_NEEDS); + const missing = boundMissing(details.missing); + if (missing !== "") payload.whats_missing = missing; + return payload; +} + +export function readTeamAccessState(storage: Pick | null = defaultStorage()): TeamAccessLocalState | null { + if (storage === null) return null; + try { + const raw = storage.getItem(TEAM_ACCESS_STORAGE_KEY); + if (raw === null || raw === "") return null; + const parsed: unknown = JSON.parse(raw); + if ( + typeof parsed === "object" + && parsed !== null + && "contactSent" in parsed + && parsed.contactSent === true + ) { + return { contactSent: true }; + } + return null; + } catch { + return null; + } +} + +export function writeTeamAccessState( + state: TeamAccessLocalState, + storage: Pick | null = defaultStorage(), +): void { + if (storage === null) return; + try { + storage.setItem(TEAM_ACCESS_STORAGE_KEY, JSON.stringify(state)); + } catch { + // Private mode or quota must not block the in-memory done state. + } +} + +export async function submitTeamAccessPayload( + payload: Record, + fetchImpl: typeof fetch = fetch, +): Promise<{ ok: true } | { ok: false; message: string }> { + if (payload.access_key.trim() === "") { + return { ok: false, message: TEAM_ACCESS_SUBMIT_ERROR }; + } + try { + const response = await fetchImpl(WEB3FORMS_SUBMIT_URL, { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + }, + body: JSON.stringify(payload), + }); + const body: unknown = await response.json().catch(() => null); + if (response.ok && isWeb3FormsSuccess(body)) return { ok: true }; + return { ok: false, message: TEAM_ACCESS_SUBMIT_ERROR }; + } catch { + return { ok: false, message: TEAM_ACCESS_SUBMIT_ERROR }; + } +} + +function isWeb3FormsSuccess(body: unknown): boolean { + return typeof body === "object" && body !== null && "success" in body && body.success === true; +} + +function defaultStorage(): Storage | null { + try { + return window.localStorage; + } catch { + return null; + } +} diff --git a/packages/hub-web/src/pages/HomeOverview.tsx b/packages/hub-web/src/pages/HomeOverview.tsx index 91c433f6..6269722c 100644 --- a/packages/hub-web/src/pages/HomeOverview.tsx +++ b/packages/hub-web/src/pages/HomeOverview.tsx @@ -8,7 +8,6 @@ import { CheckCircle2, ChevronDown, CircleDashed, - ExternalLink, GitBranch, LoaderCircle, Network, @@ -80,6 +79,7 @@ import { activitySubjectRoute, } from "../lib/activity-presentation"; import { graphParseComposition, shortRepositoryHead } from "../lib/health-presentation"; +import { TeamAccessCard } from "./TeamAccessCard"; import homeStyles from "../styles/home.module.css"; type FocusPanel = Extract; @@ -513,47 +513,6 @@ function ActivityRow({ item }: { item: ActivityItem }) { ); } -/** Hosted voluntary feedback; no address or installation ID passes through the Hub. */ -const UPDATES_FORM = "https://tally.so/r/KYjv4k"; - -/** - * The card is permanent and carries no dismissal, which is why it has to stay - * quiet. Anything that cannot be put away has to be worth living with on every - * visit, so this one states its offer once and never asks twice — no badge, no - * count, nothing that reads as unresolved work. - */ -function UpdatesSignupCard() { - return ( - - -
-

Help shape MEX

-
-
- -

- Tell us how you use MEX and what we should improve. -

-
- {/* - * The trailing arrow is the only remaining cue that this leaves the - * Hub for a new tab, so it stays where the mail glyph did not. - */} - -
-
-
- ); -} - function LatestActivityCard({ activity, onRetry }: { activity: OverviewResponse["activity"]; onRetry: () => void }) { return ( @@ -1004,7 +963,7 @@ export function HomeOverview() {
void refresh()} /> - +
diff --git a/packages/hub-web/src/pages/TeamAccessCard.test.tsx b/packages/hub-web/src/pages/TeamAccessCard.test.tsx new file mode 100644 index 00000000..2f375d49 --- /dev/null +++ b/packages/hub-web/src/pages/TeamAccessCard.test.tsx @@ -0,0 +1,191 @@ +import { cleanup, render, screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + TEAM_ACCESS_FOLLOW_UP_SUBJECT, + TEAM_ACCESS_STORAGE_KEY, + TEAM_ACCESS_SUBJECT, + WEB3FORMS_SUBMIT_URL, + __setWeb3FormsAccessKeyForTests, +} from "../lib/team-access-lead"; +import { TeamAccessCard } from "./TeamAccessCard"; + +function postedBodies() { + return vi.mocked(fetch).mock.calls.map((call) => JSON.parse(String((call[1] as RequestInit).body))); +} + +beforeEach(() => { + __setWeb3FormsAccessKeyForTests("public-test-key"); + window.localStorage.removeItem(TEAM_ACCESS_STORAGE_KEY); + vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ success: true }), + })); +}); + +afterEach(() => { + cleanup(); + __setWeb3FormsAccessKeyForTests(null); + window.localStorage.removeItem(TEAM_ACCESS_STORAGE_KEY); + vi.unstubAllGlobals(); +}); + +describe("TeamAccessCard", () => { + it("opens an in-Hub contact step with only name and email", async () => { + const user = userEvent.setup(); + render(); + + expect(screen.getByRole("heading", { name: "From mex" })).toBeVisible(); + expect(screen.getByText("This Hub already works with your team.")).toBeVisible(); + expect(screen.getByText("Design-partner access is open for shared team memory.")).toBeVisible(); + await user.click(screen.getByRole("button", { name: "Request access" })); + + const dialog = await screen.findByRole("dialog"); + expect(within(dialog).getByRole("heading", { name: "Request access" })).toBeVisible(); + expect(within(dialog).getByLabelText("Name")).toBeVisible(); + expect(within(dialog).getByLabelText("Email")).toBeVisible(); + expect(within(dialog).queryByLabelText("Company")).not.toBeInTheDocument(); + expect(within(dialog).queryByLabelText("Team size")).not.toBeInTheDocument(); + expect(within(dialog).getByText("Used only to follow up about team access.")).toBeVisible(); + expect(fetch).not.toHaveBeenCalled(); + }); + + it("sends the lead on the first valid submit, then asks optional questions", async () => { + const user = userEvent.setup(); + render(); + await user.click(screen.getByRole("button", { name: "Request access" })); + const dialog = await screen.findByRole("dialog"); + await user.type(within(dialog).getByLabelText("Name"), "Ada Lovelace"); + await user.type(within(dialog).getByLabelText("Email"), "ada@example.com"); + await user.click(within(dialog).getByRole("button", { name: "Request access" })); + + expect(await screen.findByRole("heading", { name: "You’re on the list" })).toBeVisible(); + expect(fetch).toHaveBeenCalledOnce(); + expect(vi.mocked(fetch).mock.calls[0]?.[0]).toBe(WEB3FORMS_SUBMIT_URL); + expect(postedBodies()[0]).toEqual({ + access_key: "public-test-key", + name: "Ada Lovelace", + email: "ada@example.com", + subject: TEAM_ACCESS_SUBJECT, + from_name: "mex Hub", + source: "mex-hub", + }); + expect(within(screen.getByRole("dialog")).getByLabelText("Company")).toBeVisible(); + expect(within(screen.getByRole("dialog")).getByLabelText("Team size")).toBeVisible(); + expect(within(screen.getByRole("dialog")).getByLabelText("How did you find mex?")).toBeVisible(); + expect(within(screen.getByRole("dialog")).getByLabelText("Why did you install it?")).toBeVisible(); + expect(within(screen.getByRole("dialog")).getByLabelText("Your repo is")).toBeVisible(); + expect(within(screen.getByRole("dialog")).getByLabelText("Do others use agents on this repo?")).toBeVisible(); + expect(within(screen.getByRole("dialog")).getByLabelText("I need")).toBeVisible(); + expect(within(screen.getByRole("dialog")).getByLabelText("What’s missing?")).toBeVisible(); + }); + + it("keeps the user on step 1 when Web3Forms rejects the contact submit", async () => { + vi.mocked(fetch).mockResolvedValueOnce({ + ok: false, + json: async () => ({ success: false }), + } as Response); + const user = userEvent.setup(); + render(); + await user.click(screen.getByRole("button", { name: "Request access" })); + const dialog = await screen.findByRole("dialog"); + await user.type(within(dialog).getByLabelText("Name"), "Ada Lovelace"); + await user.type(within(dialog).getByLabelText("Email"), "ada@example.com"); + await user.click(within(dialog).getByRole("button", { name: "Request access" })); + + expect(await screen.findByRole("alert")).toHaveTextContent("Could not send your request. Try again."); + expect(within(dialog).getByRole("heading", { name: "Request access" })).toBeVisible(); + expect(within(dialog).queryByLabelText("Company")).not.toBeInTheDocument(); + }); + + it("does not resubmit name and email when the optional step is skipped", async () => { + const user = userEvent.setup(); + render(); + await user.click(screen.getByRole("button", { name: "Request access" })); + const dialog = await screen.findByRole("dialog"); + await user.type(within(dialog).getByLabelText("Name"), "Ada Lovelace"); + await user.type(within(dialog).getByLabelText("Email"), "ada@example.com"); + await user.click(within(dialog).getByRole("button", { name: "Request access" })); + await screen.findByRole("heading", { name: "You’re on the list" }); + await user.click(within(screen.getByRole("dialog")).getByRole("button", { name: "Skip" })); + + expect(fetch).toHaveBeenCalledOnce(); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + expect(screen.getByText("You’re on the list. Keep using this Hub with your team.")).toBeVisible(); + expect(screen.queryByRole("button", { name: "Request access" })).not.toBeInTheDocument(); + }); + + it("keeps the captured lead if the panel is closed after the contact submit", async () => { + const user = userEvent.setup(); + render(); + await user.click(screen.getByRole("button", { name: "Request access" })); + const dialog = await screen.findByRole("dialog"); + await user.type(within(dialog).getByLabelText("Name"), "Ada Lovelace"); + await user.type(within(dialog).getByLabelText("Email"), "ada@example.com"); + await user.click(within(dialog).getByRole("button", { name: "Request access" })); + await screen.findByRole("heading", { name: "You’re on the list" }); + await user.click(within(screen.getByRole("dialog")).getByRole("button", { name: "Close" })); + + expect(fetch).toHaveBeenCalledOnce(); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + expect(screen.getByText("You’re on the list. Keep using this Hub with your team.")).toBeVisible(); + expect(screen.queryByLabelText("Name")).not.toBeInTheDocument(); + }); + + it("sends optional follow-up details with the same name and email", async () => { + const user = userEvent.setup(); + render(); + await user.click(screen.getByRole("button", { name: "Request access" })); + const dialog = await screen.findByRole("dialog"); + await user.type(within(dialog).getByLabelText("Name"), "Ada Lovelace"); + await user.type(within(dialog).getByLabelText("Email"), "ada@example.com"); + await user.click(within(dialog).getByRole("button", { name: "Request access" })); + await screen.findByRole("heading", { name: "You’re on the list" }); + const details = screen.getByRole("dialog"); + await user.type(within(details).getByLabelText("Company"), "Analytical Engines"); + await user.selectOptions(within(details).getByLabelText("Team size"), "2–10"); + await user.selectOptions(within(details).getByLabelText("How did you find mex?"), "GitHub"); + await user.selectOptions(within(details).getByLabelText("Why did you install it?"), "Agent memory"); + await user.selectOptions(within(details).getByLabelText("Your repo is"), "Work"); + await user.selectOptions(within(details).getByLabelText("Do others use agents on this repo?"), "Yes"); + await user.selectOptions(within(details).getByLabelText("I need"), "Shared team memory"); + await user.type(within(details).getByLabelText("What’s missing?"), "Shared follow-up"); + await user.click(within(details).getByRole("button", { name: "Continue" })); + + await waitFor(() => expect(fetch).toHaveBeenCalledTimes(2)); + expect(postedBodies()[1]).toEqual({ + access_key: "public-test-key", + name: "Ada Lovelace", + email: "ada@example.com", + subject: TEAM_ACCESS_FOLLOW_UP_SUBJECT, + from_name: "mex Hub", + source: "mex-hub", + company: "Analytical Engines", + team_size: "2–10", + found_mex: "GitHub", + install_reason: "Agent memory", + repo_kind: "Work", + others_use_agents: "Yes", + i_need: "Shared team memory", + whats_missing: "Shared follow-up", + }); + }); + + it("shows the done card on the next Overview render after a successful contact submit", async () => { + const user = userEvent.setup(); + const view = render(); + await user.click(screen.getByRole("button", { name: "Request access" })); + const dialog = await screen.findByRole("dialog"); + await user.type(within(dialog).getByLabelText("Name"), "Ada Lovelace"); + await user.type(within(dialog).getByLabelText("Email"), "ada@example.com"); + await user.click(within(dialog).getByRole("button", { name: "Request access" })); + await screen.findByRole("heading", { name: "You’re on the list" }); + view.unmount(); + + render(); + expect(screen.getByText("You’re on the list. Keep using this Hub with your team.")).toBeVisible(); + expect(screen.queryByRole("button", { name: "Request access" })).not.toBeInTheDocument(); + expect(screen.queryByLabelText("Name")).not.toBeInTheDocument(); + expect(screen.queryByText("Help shape MEX")).not.toBeInTheDocument(); + }); +}); diff --git a/packages/hub-web/src/pages/TeamAccessCard.tsx b/packages/hub-web/src/pages/TeamAccessCard.tsx new file mode 100644 index 00000000..12c93ac1 --- /dev/null +++ b/packages/hub-web/src/pages/TeamAccessCard.tsx @@ -0,0 +1,371 @@ +import { useId, useState, type ReactNode } from "react"; +import { CheckCircle2, Mail, ShieldCheck, type LucideIcon } from "lucide-react"; +import { Button } from "../components/primitives/button"; +import { + Card, + CardContent, + CardHeader, + CardTitle, +} from "../components/primitives/card"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "../components/primitives/dialog"; +import { + Field, + FieldError, + FieldGroup, + FieldLabel, +} from "../components/primitives/field"; +import { Input } from "../components/primitives/input"; +import { NativeSelect, NativeSelectOption } from "../components/primitives/native-select"; +import { cn } from "../lib/utils"; +import { + TEAM_ACCESS_FOUND_MEX, + TEAM_ACCESS_INSTALL_REASONS, + TEAM_ACCESS_NEEDS, + TEAM_ACCESS_OTHERS_USE_AGENTS, + TEAM_ACCESS_REPO_KINDS, + TEAM_ACCESS_TEAM_SIZES, + boundCompany, + boundEmail, + boundMissing, + boundName, + buildTeamAccessContactPayload, + buildTeamAccessFollowUpPayload, + readTeamAccessState, + submitTeamAccessPayload, + validateTeamAccessContact, + writeTeamAccessState, +} from "../lib/team-access-lead"; +import homeStyles from "../styles/home.module.css"; +import styles from "../styles/team-access.module.css"; + +type PanelStep = "contact" | "details"; + +function AccessPanelIntro({ + description, + icon: Icon, + title, + tone = "primary", +}: { + description: string; + icon: LucideIcon; + title: string; + tone?: "primary" | "success"; +}) { + return ( + +
+ +
+

From mex

+ {title} + {description} +
+
+
+ ); +} + +function AccessPanelBody({ children }: { children: ReactNode }) { + return
{children}
; +} + +function OptionalChoice({ + disabled, + id, + label, + onChange, + options, + value, +}: { + disabled: boolean; + id: string; + label: string; + onChange(value: string): void; + options: readonly string[]; + value: string; +}) { + return ( + + {label} + onChange(event.currentTarget.value)} + value={value} + > + Choose one + {options.map((option) => ( + {option} + ))} + + + ); +} + +export function TeamAccessCard() { + const [contactSent, setContactSent] = useState(() => readTeamAccessState()?.contactSent === true); + const [open, setOpen] = useState(false); + const [step, setStep] = useState("contact"); + const [name, setName] = useState(""); + const [email, setEmail] = useState(""); + const [company, setCompany] = useState(""); + const [teamSize, setTeamSize] = useState(""); + const [foundMex, setFoundMex] = useState(""); + const [installReason, setInstallReason] = useState(""); + const [repoKind, setRepoKind] = useState(""); + const [othersUseAgents, setOthersUseAgents] = useState(""); + const [need, setNeed] = useState(""); + const [missing, setMissing] = useState(""); + const [fieldErrors, setFieldErrors] = useState<{ name?: string; email?: string }>({}); + const [submitError, setSubmitError] = useState(null); + const [submitting, setSubmitting] = useState(false); + const nameId = useId(); + const emailId = useId(); + const companyId = useId(); + const teamSizeId = useId(); + const foundMexId = useId(); + const installReasonId = useId(); + const repoKindId = useId(); + const othersUseAgentsId = useId(); + const needId = useId(); + const missingId = useId(); + + const rememberContactSent = () => { + writeTeamAccessState({ contactSent: true }); + setContactSent(true); + }; + + const closePanel = (nextOpen: boolean) => { + if (submitting && !nextOpen) return; + setOpen(nextOpen); + if (nextOpen) return; + setSubmitError(null); + setFieldErrors({}); + if (step === "details") rememberContactSent(); + }; + + const submitContact = async () => { + const nextName = boundName(name); + const nextEmail = boundEmail(email); + const errors = validateTeamAccessContact(nextName, nextEmail); + setFieldErrors(errors); + setSubmitError(null); + if (errors.name || errors.email) return; + + setSubmitting(true); + try { + const result = await submitTeamAccessPayload(buildTeamAccessContactPayload({ + name: nextName, + email: nextEmail, + })); + if (!result.ok) { + setSubmitError(result.message); + return; + } + rememberContactSent(); + setName(nextName); + setEmail(nextEmail); + setStep("details"); + } finally { + setSubmitting(false); + } + }; + + const skipDetails = () => { + rememberContactSent(); + setOpen(false); + setSubmitError(null); + }; + + const submitDetails = async () => { + setSubmitError(null); + setSubmitting(true); + try { + const result = await submitTeamAccessPayload(buildTeamAccessFollowUpPayload({ + name, + email, + company: boundCompany(company), + teamSize, + foundMex, + installReason, + repoKind, + othersUseAgents, + need, + missing: boundMissing(missing), + })); + if (!result.ok) { + setSubmitError(result.message); + return; + } + rememberContactSent(); + setOpen(false); + } finally { + setSubmitting(false); + } + }; + + return ( + <> + + +
+

From mex

+
+
+ + {contactSent ? ( +

You’re on the list. Keep using this Hub with your team.

+ ) : ( + <> +

This Hub already works with your team.

+

+ Design-partner access is open for shared team memory. +

+
+ +
+ + )} +
+
+ + + + {step === "contact" ? ( +
{ + event.preventDefault(); + void submitContact(); + }} + > + + + + + Name + setName(event.currentTarget.value)} + placeholder="Ada Lovelace" + value={name} + /> + {fieldErrors.name ? {fieldErrors.name} : null} + + + Email + setEmail(event.currentTarget.value)} + placeholder="ada@example.com" + type="email" + value={email} + /> + {fieldErrors.email ? {fieldErrors.email} : null} + +

+

+ {submitError ?

{submitError}

: null} +
+
+ + + + + ) : ( +
{ + event.preventDefault(); + void submitDetails(); + }} + > + + + + + Company + setCompany(event.currentTarget.value)} + placeholder="Company name" + value={company} + /> + + + + + + + + + What’s missing? + setMissing(event.currentTarget.value)} + placeholder="One optional line" + value={missing} + /> + + {submitError ?

{submitError}

: null} +
+
+ + + + + + )} +
+
+ + ); +} diff --git a/packages/hub-web/src/styles/home.module.css b/packages/hub-web/src/styles/home.module.css index 1fe73a7c..6a7d7213 100644 --- a/packages/hub-web/src/styles/home.module.css +++ b/packages/hub-web/src/styles/home.module.css @@ -80,14 +80,11 @@ } /* - * The only card here that asks for something rather than reporting something, - * so it is drawn quieter than the panels above it: no fill of its own, a - * dimmer border, and body copy at secondary weight. It should read as a note at - * the foot of the column, never as a status the reader has to resolve. + * Team invite in the right rail. It sits with the other Overview cards instead + * of reading as a footnote that this Hub is not for teams yet. */ .updatesCard { - background: transparent; - border-color: color-mix(in oklch, var(--border) 70%, transparent); + background: var(--card); } .updatesContent { @@ -95,6 +92,14 @@ gap: 12px; } +.updatesLead { + margin: 0; + color: var(--foreground); + font-size: 13px; + font-weight: 520; + line-height: 1.45; +} + .updatesBody { margin: 0; color: var(--text-secondary); diff --git a/packages/hub-web/src/styles/team-access.module.css b/packages/hub-web/src/styles/team-access.module.css new file mode 100644 index 00000000..e9126c2a --- /dev/null +++ b/packages/hub-web/src/styles/team-access.module.css @@ -0,0 +1,204 @@ +.dialog { + width: min(460px, calc(100vw - 32px)); + max-width: 460px; + max-height: min(88vh, 760px); + gap: 0; + padding: 0; + overflow: hidden; + background: var(--card); + border: 1px solid var(--border-strong); + box-shadow: var(--overlay-shadow); +} + +.dialog [data-slot="dialog-close"] { + top: 14px; + right: 14px; +} + +.form { + display: grid; + grid-template-rows: auto minmax(0, 1fr) auto; + min-height: 0; + max-height: min(88vh, 760px); +} + +.header { + padding: 22px 48px 0 22px; +} + +.intro { + display: grid; + grid-template-columns: 40px minmax(0, 1fr); + gap: 13px; + align-items: start; +} + +.mark { + display: grid; + width: 40px; + height: 40px; + place-items: center; + color: var(--primary); + background: var(--primary-soft); + border: 1px solid var(--primary-border); + border-radius: 11px; +} + +.mark[data-tone="success"] { + color: var(--success); + background: var(--success-soft); + border-color: var(--success-border); +} + +.mark svg { + width: 18px; + height: 18px; +} + +.eyebrow { + margin: 0 0 6px; + color: var(--primary); + font-size: 10px; + font-weight: 580; + letter-spacing: 0.13em; + text-transform: uppercase; +} + +.copy { + min-width: 0; +} + +.title { + letter-spacing: -0.022em; +} + +.description { + margin-top: 7px; + font-size: 13px; + line-height: 1.5; +} + +.body { + min-height: 0; + max-height: min(52vh, 440px); + padding: 18px 22px 6px; + overflow-y: auto; +} + +.fields { + display: grid; + gap: 13px; +} + +.fields [data-slot="field"] { + display: grid; + gap: 7px; +} + +.fields [data-slot="field-label"] { + color: var(--foreground); + font-size: 12.5px; + font-weight: 560; +} + +.fields [data-slot="field-error"] { + color: var(--destructive); + font-size: 12px; +} + +.fields [data-slot="input"] { + height: 36px; + padding: 0 11px; + color: var(--foreground); + background: var(--background); + border-color: var(--border-strong); + font-size: 13px; +} + +.fields [data-slot="input"]:focus-visible { + border-color: var(--ring); + box-shadow: 0 0 0 3px color-mix(in oklch, var(--ring) 28%, transparent); +} + +.privacy { + display: grid; + grid-template-columns: 14px minmax(0, 1fr); + gap: 8px; + align-items: start; + margin: 1px 0 0; + padding: 9px 11px; + color: var(--muted-foreground); + background: var(--surface-subtle); + border: 1px solid var(--border); + border-radius: var(--radius-md); + font-size: 12px; + line-height: 1.45; +} + +.privacy svg { + width: 14px; + height: 14px; + margin-top: 1px; + color: var(--text-tertiary); +} + +.submitError { + margin: 0; + color: var(--destructive); + font-size: 12px; + line-height: 1.5; +} + +.dialog .footer { + margin: 0; + padding: 14px 22px 18px; + background: transparent; + border-top: 1px solid var(--border); + border-radius: 0; +} + +.fields [data-slot="native-select-wrapper"] { + position: relative; + width: 100%; +} + +.fields [data-slot="native-select"] { + width: 100%; + min-width: 0; + height: 36px; + padding: 4px 32px 4px 11px; + color: var(--foreground); + font: inherit; + font-size: 13px; + appearance: none; + background: var(--background); + border: 1px solid var(--border-strong); + border-radius: var(--radius-lg); + outline: none; +} + +.fields [data-slot="native-select"]:focus-visible { + border-color: var(--ring); + box-shadow: 0 0 0 3px color-mix(in oklch, var(--ring) 28%, transparent); +} + +.fields [data-slot="native-select"]:disabled { + cursor: not-allowed; + opacity: 0.5; +} + +.fields [data-slot="native-select-icon"] { + position: absolute; + top: 50%; + right: 10px; + width: 16px; + height: 16px; + color: var(--muted-foreground); + pointer-events: none; + transform: translateY(-50%); +} + +.fields [data-slot="native-select-option"] { + color: CanvasText; + background: Canvas; +} diff --git a/packages/hub-web/src/test/setup.ts b/packages/hub-web/src/test/setup.ts index 0f80c486..b01d0e1e 100644 --- a/packages/hub-web/src/test/setup.ts +++ b/packages/hub-web/src/test/setup.ts @@ -2,6 +2,11 @@ import "@testing-library/jest-dom/vitest"; import { cleanup } from "@testing-library/react"; import { afterEach } from "vitest"; +afterEach(() => { + cleanup(); + window.localStorage.removeItem("mex.hub.team-access.v1"); +}); + afterEach(() => { cleanup(); }); diff --git a/src/hub/app.ts b/src/hub/app.ts index 5b9d0391..9a2e8764 100644 --- a/src/hub/app.ts +++ b/src/hub/app.ts @@ -1072,7 +1072,7 @@ function serveAsset(context: Context, assets: HubAssetManifest | undefined): Res function applySecurityHeaders(response: Response, requestId: string, apiResponse: boolean): void { response.headers.set( "content-security-policy", - "default-src 'self'; base-uri 'none'; connect-src 'self'; font-src 'self'; " + "default-src 'self'; base-uri 'none'; connect-src 'self' https://api.web3forms.com; font-src 'self'; " + "form-action 'self'; frame-ancestors 'none'; img-src 'self' data:; " + "object-src 'none'; script-src 'self'; style-src 'self'", ); From 64eab39d0191c370cf4ba7f8286b9aa10c341229 Mon Sep 17 00:00:00 2001 From: theDakshJaitly Date: Thu, 10 Sep 2026 15:25:33 +0530 Subject: [PATCH 2/2] fix(hub): lazy-load team access and bound submissions --- .mex/patterns/secure-local-project-hub.md | 4 + .../scripts/assert-production-build.mjs | 13 + .../hub-web/src/lib/team-access-lead.test.ts | 76 ++++ packages/hub-web/src/lib/team-access-lead.ts | 88 ++--- packages/hub-web/src/lib/team-access-state.ts | 45 +++ .../hub-web/src/pages/TeamAccessCard.test.tsx | 72 +++- packages/hub-web/src/pages/TeamAccessCard.tsx | 350 +----------------- .../hub-web/src/pages/TeamAccessDialog.tsx | 342 +++++++++++++++++ test/hub-e2e/hub.spec.ts | 17 + 9 files changed, 621 insertions(+), 386 deletions(-) create mode 100644 packages/hub-web/src/lib/team-access-state.ts create mode 100644 packages/hub-web/src/pages/TeamAccessDialog.tsx diff --git a/.mex/patterns/secure-local-project-hub.md b/.mex/patterns/secure-local-project-hub.md index 93d1a7a1..066b1873 100644 --- a/.mex/patterns/secure-local-project-hub.md +++ b/.mex/patterns/secure-local-project-hub.md @@ -99,6 +99,10 @@ preview/apply services. optional follow-up) from the browser to `https://api.web3forms.com`. CSP `connect-src` names that host only; Hub must not proxy repo, path, graph, or machine data, and page load must not initiate that request. +- Keep optional Home dialogs and their styles behind an explicit open-on-demand + import; even shared dialog controls can exceed Home's frozen asset budget. + Bound external submissions across both the request and response-body read, + abort on expiry, and release the form for retry or dismissal after failure. - New read surfaces need successful-job cache invalidation as well as their initial query. Context's graph, selected record, and compact code queries all diff --git a/packages/hub-web/scripts/assert-production-build.mjs b/packages/hub-web/scripts/assert-production-build.mjs index 8d496372..d0d914c1 100644 --- a/packages/hub-web/scripts/assert-production-build.mjs +++ b/packages/hub-web/scripts/assert-production-build.mjs @@ -55,6 +55,19 @@ if (!homeEntry || workbenchEntries.some((entry) => entry !== homeEntry && entry. throw new Error("The production Hub Home workbench is not isolated in its own lazy chunk."); } const homeChunks = staticImportClosure(homeEntry.key); +const teamAccessDialogKey = Object.keys(manifest).find((candidate) => ( + candidate === "src/pages/TeamAccessDialog.tsx" + || manifest[candidate].src === "src/pages/TeamAccessDialog.tsx" +)); +if ( + !teamAccessDialogKey + || !manifest[teamAccessDialogKey].isDynamicEntry + || !(manifest[homeEntry.key].dynamicImports ?? []).includes(teamAccessDialogKey) + || homeChunks.has(teamAccessDialogKey) + || initialChunks.has(teamAccessDialogKey) +) { + throw new Error("The team-access dialog is not isolated behind its explicit open-on-demand boundary."); +} for (const entry of workbenchEntries) { if (entry !== homeEntry && homeChunks.has(entry.key)) { throw new Error(`The production Hub Home workbench eagerly imports ${entry.source}.`); diff --git a/packages/hub-web/src/lib/team-access-lead.test.ts b/packages/hub-web/src/lib/team-access-lead.test.ts index 359caf32..65a6aa37 100644 --- a/packages/hub-web/src/lib/team-access-lead.test.ts +++ b/packages/hub-web/src/lib/team-access-lead.test.ts @@ -16,6 +16,7 @@ import { } from "./team-access-lead"; afterEach(() => { + vi.useRealTimers(); __setWeb3FormsAccessKeyForTests(null); window.localStorage.removeItem(TEAM_ACCESS_STORAGE_KEY); }); @@ -163,6 +164,81 @@ describe("team-access Web3Forms submit", () => { expect(result).toEqual({ ok: false, message: TEAM_ACCESS_SUBMIT_ERROR }); }); + it("aborts and settles after fifteen seconds when the request stalls", async () => { + vi.useFakeTimers(); + const fetchImpl = vi.fn((_url: RequestInfo | URL, _init?: RequestInit) => new Promise(() => {})); + const settled = vi.fn(); + const result = submitTeamAccessPayload( + { access_key: "public-test-key", name: "Ada", email: "ada@example.com" }, + fetchImpl, + ).then(settled); + const signal = fetchImpl.mock.calls[0]?.[1]?.signal; + + await vi.advanceTimersByTimeAsync(14_999); + expect(settled).not.toHaveBeenCalled(); + expect(signal?.aborted).toBe(false); + await vi.advanceTimersByTimeAsync(1); + await result; + + expect(settled).toHaveBeenCalledExactlyOnceWith({ ok: false, message: TEAM_ACCESS_SUBMIT_ERROR }); + expect(signal?.aborted).toBe(true); + expect(vi.getTimerCount()).toBe(0); + }); + + it.each(["rejects", "ignores"] as const)( + "bounds a stalled response body when its reader %s the abort", + async (abortBehavior) => { + vi.useFakeTimers(); + const bodyAborted = vi.fn(); + const readBody = vi.fn(); + const fetchImpl = vi.fn((_url: RequestInfo | URL, init?: RequestInit) => { + readBody.mockImplementation(() => new Promise((_resolve, reject) => { + init?.signal?.addEventListener("abort", () => { + bodyAborted(); + if (abortBehavior === "rejects") reject(new DOMException("Aborted", "AbortError")); + }, { once: true }); + })); + return Promise.resolve({ ok: true, json: readBody } as unknown as Response); + }); + const result = submitTeamAccessPayload( + { access_key: "public-test-key", name: "Ada", email: "ada@example.com" }, + fetchImpl, + ); + + await vi.advanceTimersByTimeAsync(0); + expect(readBody).toHaveBeenCalledOnce(); + await vi.advanceTimersByTimeAsync(15_000); + + await expect(result).resolves.toEqual({ ok: false, message: TEAM_ACCESS_SUBMIT_ERROR }); + expect(bodyAborted).toHaveBeenCalledOnce(); + expect(vi.getTimerCount()).toBe(0); + }, + ); + + it.each(["success", "rejection"] as const)( + "clears the deadline after early %s without later aborting the request", + async (outcome) => { + vi.useFakeTimers(); + const fetchImpl = vi.fn((_url: RequestInfo | URL, _init?: RequestInit) => ( + outcome === "success" + ? Promise.resolve({ ok: true, json: async () => ({ success: true }) } as Response) + : Promise.reject(new Error("Network unavailable")) + )); + const result = await submitTeamAccessPayload( + { access_key: "public-test-key", name: "Ada", email: "ada@example.com" }, + fetchImpl, + ); + const signal = fetchImpl.mock.calls[0]?.[1]?.signal; + + expect(result).toEqual(outcome === "success" + ? { ok: true } + : { ok: false, message: TEAM_ACCESS_SUBMIT_ERROR }); + expect(vi.getTimerCount()).toBe(0); + await vi.advanceTimersByTimeAsync(15_000); + expect(signal?.aborted).toBe(false); + }, + ); + it("does not call the network when the public access key is missing", async () => { const fetchImpl = vi.fn(); const result = await submitTeamAccessPayload( diff --git a/packages/hub-web/src/lib/team-access-lead.ts b/packages/hub-web/src/lib/team-access-lead.ts index 2ced2e5c..7568534d 100644 --- a/packages/hub-web/src/lib/team-access-lead.ts +++ b/packages/hub-web/src/lib/team-access-lead.ts @@ -9,7 +9,12 @@ export const WEB3FORMS_ACCESS_KEY = "20549db8-9c62-4da9-920a-f70a08c8ee44"; export const WEB3FORMS_SUBMIT_URL = "https://api.web3forms.com/submit"; -export const TEAM_ACCESS_STORAGE_KEY = "mex.hub.team-access.v1"; +export { + TEAM_ACCESS_STORAGE_KEY, + readTeamAccessState, + writeTeamAccessState, + type TeamAccessLocalState, +} from "./team-access-state"; export const TEAM_ACCESS_SUBJECT = "mex Hub team access"; export const TEAM_ACCESS_FOLLOW_UP_SUBJECT = "mex Hub team access follow-up"; export const TEAM_ACCESS_FROM_NAME = "mex Hub"; @@ -39,14 +44,11 @@ export interface TeamAccessFollowUp extends TeamAccessContact { missing: string; } -export interface TeamAccessLocalState { - contactSent: true; -} - const NAME_MAX = 200; const EMAIL_MAX = 320; const COMPANY_MAX = 200; const MISSING_MAX = 240; +const SUBMIT_TIMEOUT_MS = 15_000; let accessKeyOverride: string | null = null; @@ -132,38 +134,6 @@ export function buildTeamAccessFollowUpPayload(details: TeamAccessFollowUp, acce return payload; } -export function readTeamAccessState(storage: Pick | null = defaultStorage()): TeamAccessLocalState | null { - if (storage === null) return null; - try { - const raw = storage.getItem(TEAM_ACCESS_STORAGE_KEY); - if (raw === null || raw === "") return null; - const parsed: unknown = JSON.parse(raw); - if ( - typeof parsed === "object" - && parsed !== null - && "contactSent" in parsed - && parsed.contactSent === true - ) { - return { contactSent: true }; - } - return null; - } catch { - return null; - } -} - -export function writeTeamAccessState( - state: TeamAccessLocalState, - storage: Pick | null = defaultStorage(), -): void { - if (storage === null) return; - try { - storage.setItem(TEAM_ACCESS_STORAGE_KEY, JSON.stringify(state)); - } catch { - // Private mode or quota must not block the in-memory done state. - } -} - export async function submitTeamAccessPayload( payload: Record, fetchImpl: typeof fetch = fetch, @@ -171,31 +141,41 @@ export async function submitTeamAccessPayload( if (payload.access_key.trim() === "") { return { ok: false, message: TEAM_ACCESS_SUBMIT_ERROR }; } + const controller = new AbortController(); + let timeout: ReturnType | undefined; try { - const response = await fetchImpl(WEB3FORMS_SUBMIT_URL, { - method: "POST", - headers: { - Accept: "application/json", - "Content-Type": "application/json", - }, - body: JSON.stringify(payload), + const expired = new Promise((resolve) => { + timeout = setTimeout(() => { + controller.abort(); + resolve(false); + }, SUBMIT_TIMEOUT_MS); }); - const body: unknown = await response.json().catch(() => null); - if (response.ok && isWeb3FormsSuccess(body)) return { ok: true }; + // Race the complete read so a stalled response body cannot lock the dialog. + const accepted = await Promise.race([ + (async () => { + const response = await fetchImpl(WEB3FORMS_SUBMIT_URL, { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + }, + body: JSON.stringify(payload), + signal: controller.signal, + }); + const body: unknown = await response.json().catch(() => null); + return response.ok && isWeb3FormsSuccess(body); + })(), + expired, + ]); + if (accepted) return { ok: true }; return { ok: false, message: TEAM_ACCESS_SUBMIT_ERROR }; } catch { return { ok: false, message: TEAM_ACCESS_SUBMIT_ERROR }; + } finally { + if (timeout !== undefined) clearTimeout(timeout); } } function isWeb3FormsSuccess(body: unknown): boolean { return typeof body === "object" && body !== null && "success" in body && body.success === true; } - -function defaultStorage(): Storage | null { - try { - return window.localStorage; - } catch { - return null; - } -} diff --git a/packages/hub-web/src/lib/team-access-state.ts b/packages/hub-web/src/lib/team-access-state.ts new file mode 100644 index 00000000..125a14ec --- /dev/null +++ b/packages/hub-web/src/lib/team-access-state.ts @@ -0,0 +1,45 @@ +export const TEAM_ACCESS_STORAGE_KEY = "mex.hub.team-access.v1"; + +export interface TeamAccessLocalState { + contactSent: true; +} + +export function readTeamAccessState(storage: Pick | null = defaultStorage()): TeamAccessLocalState | null { + if (storage === null) return null; + try { + const raw = storage.getItem(TEAM_ACCESS_STORAGE_KEY); + if (raw === null || raw === "") return null; + const parsed: unknown = JSON.parse(raw); + if ( + typeof parsed === "object" + && parsed !== null + && "contactSent" in parsed + && parsed.contactSent === true + ) { + return { contactSent: true }; + } + return null; + } catch { + return null; + } +} + +export function writeTeamAccessState( + state: TeamAccessLocalState, + storage: Pick | null = defaultStorage(), +): void { + if (storage === null) return; + try { + storage.setItem(TEAM_ACCESS_STORAGE_KEY, JSON.stringify(state)); + } catch { + // Private mode or quota must not block the in-memory done state. + } +} + +function defaultStorage(): Storage | null { + try { + return window.localStorage; + } catch { + return null; + } +} diff --git a/packages/hub-web/src/pages/TeamAccessCard.test.tsx b/packages/hub-web/src/pages/TeamAccessCard.test.tsx index 2f375d49..87f1ada4 100644 --- a/packages/hub-web/src/pages/TeamAccessCard.test.tsx +++ b/packages/hub-web/src/pages/TeamAccessCard.test.tsx @@ -1,4 +1,4 @@ -import { cleanup, render, screen, waitFor, within } from "@testing-library/react"; +import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { @@ -25,12 +25,82 @@ beforeEach(() => { afterEach(() => { cleanup(); + vi.useRealTimers(); __setWeb3FormsAccessKeyForTests(null); window.localStorage.removeItem(TEAM_ACCESS_STORAGE_KEY); vi.unstubAllGlobals(); }); describe("TeamAccessCard", () => { + it("preserves unsent contact details when the lazy dialog is reopened", async () => { + const user = userEvent.setup(); + render(); + await user.click(screen.getByRole("button", { name: "Request access" })); + const dialog = await screen.findByRole("dialog"); + await user.type(within(dialog).getByLabelText("Name"), "Ada Lovelace"); + await user.type(within(dialog).getByLabelText("Email"), "ada@example.com"); + await user.keyboard("{Escape}"); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + await waitFor(() => expect(screen.getByRole("button", { name: "Request access" })).toHaveFocus()); + await user.click(screen.getByRole("button", { name: "Request access" })); + const reopened = await screen.findByRole("dialog"); + expect(within(reopened).getByLabelText("Name")).toHaveValue("Ada Lovelace"); + expect(within(reopened).getByLabelText("Email")).toHaveValue("ada@example.com"); + expect(fetch).not.toHaveBeenCalled(); + }); + + it("unlocks a stalled contact request and lets the user close and retry", async () => { + vi.mocked(fetch).mockImplementationOnce(() => new Promise(() => {})); + const user = userEvent.setup(); + render(); + await user.click(screen.getByRole("button", { name: "Request access" })); + const dialog = await screen.findByRole("dialog"); + await user.type(within(dialog).getByLabelText("Name"), "Ada Lovelace"); + await user.type(within(dialog).getByLabelText("Email"), "ada@example.com"); + vi.useFakeTimers(); + fireEvent.submit(within(dialog).getByRole("button", { name: "Request access" }).closest("form")!); + expect(within(dialog).getByRole("button", { name: "Sending…" })).toBeDisabled(); + await act(async () => { await vi.advanceTimersByTimeAsync(15_000); }); + vi.useRealTimers(); + + expect(screen.getByRole("alert")).toHaveTextContent("Could not send your request. Try again."); + expect(vi.mocked(fetch).mock.calls[0]?.[1]?.signal?.aborted).toBe(true); + expect(within(dialog).getByLabelText("Email")).toBeEnabled(); + expect(window.localStorage.getItem(TEAM_ACCESS_STORAGE_KEY)).toBeNull(); + await user.click(within(dialog).getByRole("button", { name: "Close" })); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "Request access" })); + const reopened = await screen.findByRole("dialog"); + expect(within(reopened).getByLabelText("Email")).toHaveValue("ada@example.com"); + await user.click(within(reopened).getByRole("button", { name: "Request access" })); + expect(await screen.findByRole("heading", { name: "You’re on the list" })).toBeVisible(); + expect(fetch).toHaveBeenCalledTimes(2); + }); + + it("allows skipping a stalled optional submission after its deadline", async () => { + const user = userEvent.setup(); + render(); + await user.click(screen.getByRole("button", { name: "Request access" })); + const dialog = await screen.findByRole("dialog"); + await user.type(within(dialog).getByLabelText("Name"), "Ada Lovelace"); + await user.type(within(dialog).getByLabelText("Email"), "ada@example.com"); + await user.click(within(dialog).getByRole("button", { name: "Request access" })); + await screen.findByRole("heading", { name: "You’re on the list" }); + await user.type(within(dialog).getByLabelText("Company"), "Analytical Engines"); + vi.mocked(fetch).mockImplementationOnce(() => new Promise(() => {})); + vi.useFakeTimers(); + fireEvent.submit(within(dialog).getByRole("button", { name: "Continue" }).closest("form")!); + await act(async () => { await vi.advanceTimersByTimeAsync(15_000); }); + vi.useRealTimers(); + + expect(screen.getByRole("alert")).toHaveTextContent("Could not send your request. Try again."); + expect(within(dialog).getByLabelText("Company")).toHaveValue("Analytical Engines"); + await user.click(within(dialog).getByRole("button", { name: "Skip" })); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + expect(screen.getByText("You’re on the list. Keep using this Hub with your team.")).toBeVisible(); + expect(fetch).toHaveBeenCalledTimes(2); + }); + it("opens an in-Hub contact step with only name and email", async () => { const user = userEvent.setup(); render(); diff --git a/packages/hub-web/src/pages/TeamAccessCard.tsx b/packages/hub-web/src/pages/TeamAccessCard.tsx index 12c93ac1..99127ee7 100644 --- a/packages/hub-web/src/pages/TeamAccessCard.tsx +++ b/packages/hub-web/src/pages/TeamAccessCard.tsx @@ -1,221 +1,26 @@ -import { useId, useState, type ReactNode } from "react"; -import { CheckCircle2, Mail, ShieldCheck, type LucideIcon } from "lucide-react"; +import { lazy, Suspense, useRef, useState } from "react"; import { Button } from "../components/primitives/button"; -import { - Card, - CardContent, - CardHeader, - CardTitle, -} from "../components/primitives/card"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "../components/primitives/dialog"; -import { - Field, - FieldError, - FieldGroup, - FieldLabel, -} from "../components/primitives/field"; -import { Input } from "../components/primitives/input"; -import { NativeSelect, NativeSelectOption } from "../components/primitives/native-select"; -import { cn } from "../lib/utils"; -import { - TEAM_ACCESS_FOUND_MEX, - TEAM_ACCESS_INSTALL_REASONS, - TEAM_ACCESS_NEEDS, - TEAM_ACCESS_OTHERS_USE_AGENTS, - TEAM_ACCESS_REPO_KINDS, - TEAM_ACCESS_TEAM_SIZES, - boundCompany, - boundEmail, - boundMissing, - boundName, - buildTeamAccessContactPayload, - buildTeamAccessFollowUpPayload, - readTeamAccessState, - submitTeamAccessPayload, - validateTeamAccessContact, - writeTeamAccessState, -} from "../lib/team-access-lead"; +import { Card, CardContent, CardHeader, CardTitle } from "../components/primitives/card"; +import { readTeamAccessState, writeTeamAccessState } from "../lib/team-access-state"; import homeStyles from "../styles/home.module.css"; -import styles from "../styles/team-access.module.css"; -type PanelStep = "contact" | "details"; - -function AccessPanelIntro({ - description, - icon: Icon, - title, - tone = "primary", -}: { - description: string; - icon: LucideIcon; - title: string; - tone?: "primary" | "success"; -}) { - return ( - -
- -
-

From mex

- {title} - {description} -
-
-
- ); -} - -function AccessPanelBody({ children }: { children: ReactNode }) { - return
{children}
; -} - -function OptionalChoice({ - disabled, - id, - label, - onChange, - options, - value, -}: { - disabled: boolean; - id: string; - label: string; - onChange(value: string): void; - options: readonly string[]; - value: string; -}) { - return ( - - {label} - onChange(event.currentTarget.value)} - value={value} - > - Choose one - {options.map((option) => ( - {option} - ))} - - - ); -} +const TeamAccessDialog = lazy(() => import("./TeamAccessDialog")); export function TeamAccessCard() { const [contactSent, setContactSent] = useState(() => readTeamAccessState()?.contactSent === true); const [open, setOpen] = useState(false); - const [step, setStep] = useState("contact"); - const [name, setName] = useState(""); - const [email, setEmail] = useState(""); - const [company, setCompany] = useState(""); - const [teamSize, setTeamSize] = useState(""); - const [foundMex, setFoundMex] = useState(""); - const [installReason, setInstallReason] = useState(""); - const [repoKind, setRepoKind] = useState(""); - const [othersUseAgents, setOthersUseAgents] = useState(""); - const [need, setNeed] = useState(""); - const [missing, setMissing] = useState(""); - const [fieldErrors, setFieldErrors] = useState<{ name?: string; email?: string }>({}); - const [submitError, setSubmitError] = useState(null); - const [submitting, setSubmitting] = useState(false); - const nameId = useId(); - const emailId = useId(); - const companyId = useId(); - const teamSizeId = useId(); - const foundMexId = useId(); - const installReasonId = useId(); - const repoKindId = useId(); - const othersUseAgentsId = useId(); - const needId = useId(); - const missingId = useId(); + const [hasOpened, setHasOpened] = useState(false); + const cardRef = useRef(null); + const requestButtonRef = useRef(null); const rememberContactSent = () => { writeTeamAccessState({ contactSent: true }); setContactSent(true); }; - const closePanel = (nextOpen: boolean) => { - if (submitting && !nextOpen) return; - setOpen(nextOpen); - if (nextOpen) return; - setSubmitError(null); - setFieldErrors({}); - if (step === "details") rememberContactSent(); - }; - - const submitContact = async () => { - const nextName = boundName(name); - const nextEmail = boundEmail(email); - const errors = validateTeamAccessContact(nextName, nextEmail); - setFieldErrors(errors); - setSubmitError(null); - if (errors.name || errors.email) return; - - setSubmitting(true); - try { - const result = await submitTeamAccessPayload(buildTeamAccessContactPayload({ - name: nextName, - email: nextEmail, - })); - if (!result.ok) { - setSubmitError(result.message); - return; - } - rememberContactSent(); - setName(nextName); - setEmail(nextEmail); - setStep("details"); - } finally { - setSubmitting(false); - } - }; - - const skipDetails = () => { - rememberContactSent(); - setOpen(false); - setSubmitError(null); - }; - - const submitDetails = async () => { - setSubmitError(null); - setSubmitting(true); - try { - const result = await submitTeamAccessPayload(buildTeamAccessFollowUpPayload({ - name, - email, - company: boundCompany(company), - teamSize, - foundMex, - installReason, - repoKind, - othersUseAgents, - need, - missing: boundMissing(missing), - })); - if (!result.ok) { - setSubmitError(result.message); - return; - } - rememberContactSent(); - setOpen(false); - } finally { - setSubmitting(false); - } - }; - return ( <> - +

From mex

@@ -231,7 +36,7 @@ export function TeamAccessCard() { Design-partner access is open for shared team memory.

-
@@ -239,133 +44,16 @@ export function TeamAccessCard() { )} - - - - {step === "contact" ? ( -
{ - event.preventDefault(); - void submitContact(); - }} - > - - - - - Name - setName(event.currentTarget.value)} - placeholder="Ada Lovelace" - value={name} - /> - {fieldErrors.name ? {fieldErrors.name} : null} - - - Email - setEmail(event.currentTarget.value)} - placeholder="ada@example.com" - type="email" - value={email} - /> - {fieldErrors.email ? {fieldErrors.email} : null} - -

-

- {submitError ?

{submitError}

: null} -
-
- - - - - ) : ( -
{ - event.preventDefault(); - void submitDetails(); - }} - > - - - - - Company - setCompany(event.currentTarget.value)} - placeholder="Company name" - value={company} - /> - - - - - - - - - What’s missing? - setMissing(event.currentTarget.value)} - placeholder="One optional line" - value={missing} - /> - - {submitError ?

{submitError}

: null} -
-
- - - - - - )} -
-
+ {hasOpened ? ( + Opening access request…

}> + requestButtonRef.current ?? cardRef.current} + /> +
+ ) : null} ); } diff --git a/packages/hub-web/src/pages/TeamAccessDialog.tsx b/packages/hub-web/src/pages/TeamAccessDialog.tsx new file mode 100644 index 00000000..aa9306ab --- /dev/null +++ b/packages/hub-web/src/pages/TeamAccessDialog.tsx @@ -0,0 +1,342 @@ +import { useId, useState, type ReactNode } from "react"; +import { CheckCircle2, Mail, ShieldCheck, type LucideIcon } from "lucide-react"; +import { Button } from "../components/primitives/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "../components/primitives/dialog"; +import { + Field, + FieldError, + FieldGroup, + FieldLabel, +} from "../components/primitives/field"; +import { Input } from "../components/primitives/input"; +import { NativeSelect, NativeSelectOption } from "../components/primitives/native-select"; +import { cn } from "../lib/utils"; +import { + TEAM_ACCESS_FOUND_MEX, + TEAM_ACCESS_INSTALL_REASONS, + TEAM_ACCESS_NEEDS, + TEAM_ACCESS_OTHERS_USE_AGENTS, + TEAM_ACCESS_REPO_KINDS, + TEAM_ACCESS_TEAM_SIZES, + boundCompany, + boundEmail, + boundMissing, + boundName, + buildTeamAccessContactPayload, + buildTeamAccessFollowUpPayload, + submitTeamAccessPayload, + validateTeamAccessContact, +} from "../lib/team-access-lead"; +import styles from "../styles/team-access.module.css"; + +type PanelStep = "contact" | "details"; + +function AccessPanelIntro({ + description, + icon: Icon, + title, + tone = "primary", +}: { + description: string; + icon: LucideIcon; + title: string; + tone?: "primary" | "success"; +}) { + return ( + +
+ +
+

From mex

+ {title} + {description} +
+
+
+ ); +} + +function AccessPanelBody({ children }: { children: ReactNode }) { + return
{children}
; +} + +function OptionalChoice({ + disabled, + id, + label, + onChange, + options, + value, +}: { + disabled: boolean; + id: string; + label: string; + onChange(value: string): void; + options: readonly string[]; + value: string; +}) { + return ( + + {label} + onChange(event.currentTarget.value)} + value={value} + > + Choose one + {options.map((option) => ( + {option} + ))} + + + ); +} + +export default function TeamAccessDialog({ + open, + onOpenChange, + onContactSent, + finalFocus, +}: { + open: boolean; + onOpenChange(open: boolean): void; + onContactSent(): void; + finalFocus(): HTMLElement | null; +}) { + const [step, setStep] = useState("contact"); + const [name, setName] = useState(""); + const [email, setEmail] = useState(""); + const [company, setCompany] = useState(""); + const [teamSize, setTeamSize] = useState(""); + const [foundMex, setFoundMex] = useState(""); + const [installReason, setInstallReason] = useState(""); + const [repoKind, setRepoKind] = useState(""); + const [othersUseAgents, setOthersUseAgents] = useState(""); + const [need, setNeed] = useState(""); + const [missing, setMissing] = useState(""); + const [fieldErrors, setFieldErrors] = useState<{ name?: string; email?: string }>({}); + const [submitError, setSubmitError] = useState(null); + const [submitting, setSubmitting] = useState(false); + const nameId = useId(); + const emailId = useId(); + const companyId = useId(); + const teamSizeId = useId(); + const foundMexId = useId(); + const installReasonId = useId(); + const repoKindId = useId(); + const othersUseAgentsId = useId(); + const needId = useId(); + const missingId = useId(); + + const rememberContactSent = () => { + onContactSent(); + }; + + const closePanel = (nextOpen: boolean) => { + if (submitting && !nextOpen) return; + onOpenChange(nextOpen); + if (nextOpen) return; + setSubmitError(null); + setFieldErrors({}); + if (step === "details") rememberContactSent(); + }; + + const submitContact = async () => { + const nextName = boundName(name); + const nextEmail = boundEmail(email); + const errors = validateTeamAccessContact(nextName, nextEmail); + setFieldErrors(errors); + setSubmitError(null); + if (errors.name || errors.email) return; + + setSubmitting(true); + try { + const result = await submitTeamAccessPayload(buildTeamAccessContactPayload({ + name: nextName, + email: nextEmail, + })); + if (!result.ok) { + setSubmitError(result.message); + return; + } + rememberContactSent(); + setName(nextName); + setEmail(nextEmail); + setStep("details"); + } finally { + setSubmitting(false); + } + }; + + const skipDetails = () => { + rememberContactSent(); + onOpenChange(false); + setSubmitError(null); + }; + + const submitDetails = async () => { + setSubmitError(null); + setSubmitting(true); + try { + const result = await submitTeamAccessPayload(buildTeamAccessFollowUpPayload({ + name, + email, + company: boundCompany(company), + teamSize, + foundMex, + installReason, + repoKind, + othersUseAgents, + need, + missing: boundMissing(missing), + })); + if (!result.ok) { + setSubmitError(result.message); + return; + } + rememberContactSent(); + onOpenChange(false); + } finally { + setSubmitting(false); + } + }; + + return ( + + + {step === "contact" ? ( +
{ + event.preventDefault(); + void submitContact(); + }} + > + + + + + Name + setName(event.currentTarget.value)} + placeholder="Ada Lovelace" + value={name} + /> + {fieldErrors.name ? {fieldErrors.name} : null} + + + Email + setEmail(event.currentTarget.value)} + placeholder="ada@example.com" + type="email" + value={email} + /> + {fieldErrors.email ? {fieldErrors.email} : null} + +

+

+ {submitError ?

{submitError}

: null} +
+
+ + + + + ) : ( +
{ + event.preventDefault(); + void submitDetails(); + }} + > + + + + + Company + setCompany(event.currentTarget.value)} + placeholder="Company name" + value={company} + /> + + + + + + + + + What’s missing? + setMissing(event.currentTarget.value)} + placeholder="One optional line" + value={missing} + /> + + {submitError ?

{submitError}

: null} +
+
+ + + + + + )} +
+
+ ); +} diff --git a/test/hub-e2e/hub.spec.ts b/test/hub-e2e/hub.spec.ts index c920bf64..578b8221 100644 --- a/test/hub-e2e/hub.spec.ts +++ b/test/hub-e2e/hub.spec.ts @@ -2412,6 +2412,7 @@ test.describe("built production Hub", () => { const errors = watchBrowserErrors(page); const productionOrigin = new URL(bootstrapUrl).origin; const crossOriginRequests: string[] = []; + const teamAccessDialogRequests: string[] = []; const idleApiRequests: string[] = []; const relayDraftRequests: string[] = []; const relayWorkstreamRequests: string[] = []; @@ -2419,6 +2420,7 @@ test.describe("built production Hub", () => { page.on("request", (request) => { const url = new URL(request.url()); if (url.origin !== productionOrigin) crossOriginRequests.push(request.url()); + if (/\/TeamAccessDialog-[^/]+\.js$/u.test(url.pathname)) teamAccessDialogRequests.push(request.url()); if (url.origin === productionOrigin && url.pathname === "/api/v1/relays/drafts") { relayDraftRequests.push(request.url()); } @@ -2432,6 +2434,21 @@ test.describe("built production Hub", () => { const response = await page.goto(bootstrapUrl); await expect(page.locator('[data-overview-workbench="ready"]')).toBeVisible(); await expect.poll(() => page.url()).not.toContain("#token="); + expect(teamAccessDialogRequests).toEqual([]); + const requestAccess = page.getByRole("button", { name: "Request access", exact: true }); + await requestAccess.click(); + const teamAccessDialog = page.getByRole("dialog", { name: "Request access", exact: true }); + await expect(teamAccessDialog).toBeVisible(); + await expect(teamAccessDialog.getByRole("textbox", { name: "Name", exact: true })).toBeFocused(); + expect(teamAccessDialogRequests).toHaveLength(1); + for (const width of [1024, 1440]) { + await page.setViewportSize({ width, height: 900 }); + await expectAccessible(page); + await expectNoHorizontalOverflow(page, width); + } + await page.keyboard.press("Escape"); + await expect(teamAccessDialog).toBeHidden(); + await expect(requestAccess).toBeFocused(); await expect(page.getByRole("link", { name: "Context", exact: true })).toBeVisible(); await expect(page.getByRole("link", { name: "Code", exact: true })).toBeVisible(); await expect(page.getByText("Three knowledge pages lost grounding")).toHaveCount(0);