From a9150becfff2bcda430e3c8b171c6c06429a1f2c Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Sun, 11 Oct 2026 17:45:19 -0700 Subject: [PATCH] Tell users when their organization has moved to Executor v2 Reads the move v2 records on the WorkOS organization (executor_v2_org_slug, executor_v2_moved_at) into the v2-import answer, shows a dismissible notice on the dashboard with a feedback step for staying on v1, and points the sidebar link at the org in v2. --- .changeset/v2-moved-notice.md | 5 + apps/cloud/src/auth/api.ts | 11 + apps/cloud/src/auth/handlers.ts | 18 +- apps/cloud/src/auth/v2-import.ts | 51 ++++- apps/cloud/src/auth/v2-moved.ts | 86 ++++++++ .../src/web/components/v2-import-link.tsx | 23 +- .../src/web/components/v2-moved-notice.tsx | 205 ++++++++++++++++++ apps/cloud/src/web/shell.tsx | 7 +- apps/cloud/src/web/v2-moved-snooze.ts | 64 ++++++ packages/react/src/api/analytics.tsx | 14 +- 10 files changed, 462 insertions(+), 22 deletions(-) create mode 100644 .changeset/v2-moved-notice.md create mode 100644 apps/cloud/src/auth/v2-moved.ts create mode 100644 apps/cloud/src/web/components/v2-moved-notice.tsx create mode 100644 apps/cloud/src/web/v2-moved-snooze.ts diff --git a/.changeset/v2-moved-notice.md b/.changeset/v2-moved-notice.md new file mode 100644 index 0000000000..5222cd7246 --- /dev/null +++ b/.changeset/v2-moved-notice.md @@ -0,0 +1,5 @@ +--- +"@executor-js/cloud": patch +--- + +Tell users when their organization has moved to Executor v2, with a link to it in v2 and a way to say why they are staying on v1. diff --git a/apps/cloud/src/auth/api.ts b/apps/cloud/src/auth/api.ts index 952210ffb1..47ad43cfa7 100644 --- a/apps/cloud/src/auth/api.ts +++ b/apps/cloud/src/auth/api.ts @@ -37,8 +37,19 @@ const AuthOrganizationsResponse = Schema.Struct({ // Executor v2's workspace import for the selected org: its page URL when v2 // would let the signed-in user import the org, otherwise null. Nothing else // about the membership or billing reads behind it is returned. +// +// `moved` is set instead when the org has already been imported (v2 records +// the move on the WorkOS organization): where it lives in v2 and when it +// moved. It drives a notice only; the org keeps working in v1. +const V2MovedResponse = Schema.Struct({ + v2Url: Schema.String, + v2Slug: Schema.String, + movedAt: Schema.String, +}); + const V2ImportResponse = Schema.Struct({ url: Schema.NullOr(Schema.String), + moved: Schema.NullOr(V2MovedResponse), }); const CreateOrganizationBody = Schema.Struct({ diff --git a/apps/cloud/src/auth/handlers.ts b/apps/cloud/src/auth/handlers.ts index 92ab84cb3c..e6857d0aba 100644 --- a/apps/cloud/src/auth/handlers.ts +++ b/apps/cloud/src/auth/handlers.ts @@ -42,7 +42,8 @@ import { type AuthorizeOrganizationOptions, } from "./organization"; import { mcpSessionStub } from "@executor-js/cloudflare/mcp/session-stub"; -import { liveV2ImportSource, v2ImportEligible, v2ImportUrl } from "./v2-import"; +import { liveV2ImportSource, v2ImportAnswer } from "./v2-import"; +import { liveV2MovedSource } from "./v2-moved"; const COOKIE_OPTIONS = { path: "/", @@ -443,14 +444,13 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( .handle("v2Import", () => Effect.gen(function* () { const session = yield* requireSelectedOrganization; - const url = v2ImportUrl(env); - if (url === null) return { url: null }; - const eligible = yield* v2ImportEligible( - yield* liveV2ImportSource, - session.accountId, - session.organizationId, - ); - return { url: eligible ? url.href : null }; + return yield* v2ImportAnswer({ + env, + importSource: liveV2ImportSource, + movedSource: liveV2MovedSource, + userId: session.accountId, + organizationId: session.organizationId, + }); }), ) .handle("createOrganization", ({ payload }) => diff --git a/apps/cloud/src/auth/v2-import.ts b/apps/cloud/src/auth/v2-import.ts index 964d643207..10a811dd3b 100644 --- a/apps/cloud/src/auth/v2-import.ts +++ b/apps/cloud/src/auth/v2-import.ts @@ -15,21 +15,27 @@ import { Effect } from "effect"; import { parseV2Edge, type V2EdgeEnv } from "../edge/marketing"; import { AutumnService, type AutumnSubscription } from "../extensions/billing/service"; +import { v2Moved, type V2Moved, type V2MovedSource } from "./v2-moved"; import { WorkOSClient } from "./workos"; /** v2's import page, on v2's app host. */ const V2_IMPORT_PATH = "/v1-import"; /** - * The absolute URL of v2's import page, or `null` where v1 does not hand - * requests to v2 (local dev without the v2 settings, test workers, or broken - * settings). v2's app host is the origin of its sign-up page, the same host - * the edge sends v2 dashboard links to. + * v2's app host, or `null` where v1 does not hand requests to v2 (local dev + * without the v2 settings, test workers, or broken settings). It is the origin + * of v2's sign-up page, the same host the edge sends v2 dashboard links to. */ -export const v2ImportUrl = (env: V2EdgeEnv): URL | null => { +export const v2AppOrigin = (env: V2EdgeEnv): URL | null => { const edge = parseV2Edge(env); if (edge === null || typeof edge === "string") return null; - return new URL(V2_IMPORT_PATH, edge.signUpUrl.origin); + return new URL(edge.signUpUrl.origin); +}; + +/** The absolute URL of v2's import page, or `null` where v1 has no v2 host. */ +export const v2ImportUrl = (env: V2EdgeEnv): URL | null => { + const origin = v2AppOrigin(env); + return origin === null ? null : new URL(V2_IMPORT_PATH, origin); }; /** v1's plans that cost nothing, as v2's import counts them (v2's @@ -103,3 +109,36 @@ export const liveV2ImportSource = Effect.gen(function* () { .pipe(Effect.map((customer) => customer?.subscriptions ?? [])), } satisfies V2ImportSource; }); + +/** What the dashboard shows for the selected org: the import link, or where + * the org now lives in v2. At most one is set. */ +export interface V2ImportAnswer { + readonly url: string | null; + readonly moved: V2Moved | null; +} + +/** + * The `GET /api/auth/v2-import` answer. Without a v2 host there is nothing to + * link to. An org that has already moved is not offered the import again, so + * its eligibility is never read. Never fails. + */ +export const v2ImportAnswer = (input: { + readonly env: V2EdgeEnv; + readonly importSource: Effect.Effect; + readonly movedSource: Effect.Effect; + readonly userId: string; + readonly organizationId: string; +}): Effect.Effect => + Effect.gen(function* () { + const v2Origin = v2AppOrigin(input.env); + const importUrl = v2ImportUrl(input.env); + if (v2Origin === null || importUrl === null) return { url: null, moved: null }; + const moved = yield* v2Moved(yield* input.movedSource, input.organizationId, v2Origin); + if (moved !== null) return { url: null, moved }; + const eligible = yield* v2ImportEligible( + yield* input.importSource, + input.userId, + input.organizationId, + ); + return { url: eligible ? importUrl.href : null, moved: null }; + }); diff --git a/apps/cloud/src/auth/v2-moved.ts b/apps/cloud/src/auth/v2-moved.ts new file mode 100644 index 0000000000..4d8da5c811 --- /dev/null +++ b/apps/cloud/src/auth/v2-moved.ts @@ -0,0 +1,86 @@ +// --------------------------------------------------------------------------- +// Whether the selected organization has moved to Executor v2. +// +// When a user finishes importing a v1 organization, v2 writes two keys onto +// the v1 WorkOS organization's metadata: +// executor_v2_org_slug the v2 organization's slug (its `/org/` URL) +// executor_v2_moved_at when the import finished, as an ISO 8601 timestamp +// Both must be present and well formed; anything else, and every failure to +// read the organization, answers "not moved". The answer only drives a notice: +// a moved organization keeps working in v1. +// --------------------------------------------------------------------------- + +import { Effect } from "effect"; + +import { WorkOSClient } from "./workos"; + +export const V2_MOVED_SLUG_KEY = "executor_v2_org_slug"; +export const V2_MOVED_AT_KEY = "executor_v2_moved_at"; + +/** v2's organization slug rule (`OrganizationSlug` in v2's hosted contracts). */ +const V2_SLUG = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; +const V2_SLUG_MAX_LENGTH = 45; + +/** An ISO 8601 date and time with an explicit offset, as `Date#toISOString` + * writes it. A bare date or a local time is ambiguous, so it is refused. */ +const ISO_TIMESTAMP = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:\d{2})$/; + +export interface V2Moved { + /** The v2 organization's page: `/org/`. */ + readonly v2Url: string; + readonly v2Slug: string; + /** When the import finished, normalized to `Date#toISOString`. */ + readonly movedAt: string; +} + +/** The move recorded in an organization's metadata, or `null` when there is + * none or it is malformed. */ +export const parseV2Moved = ( + metadata: Readonly>, + v2Origin: URL, +): V2Moved | null => { + const slug = metadata[V2_MOVED_SLUG_KEY]; + const movedAt = metadata[V2_MOVED_AT_KEY]; + if (typeof slug !== "string" || slug.length > V2_SLUG_MAX_LENGTH || !V2_SLUG.test(slug)) { + return null; + } + if (typeof movedAt !== "string" || !ISO_TIMESTAMP.test(movedAt)) return null; + const time = Date.parse(movedAt); + if (!Number.isFinite(time)) return null; + return { + v2Url: new URL(`/org/${slug}`, v2Origin).href, + v2Slug: slug, + movedAt: new Date(time).toISOString(), + }; +}; + +/** The read the check makes. */ +export interface V2MovedSource { + readonly organizationMetadata: ( + organizationId: string, + ) => Effect.Effect>, unknown>; +} + +/** Whether the organization has moved to v2. Never fails: an unavailable + * read answers `null`, so a WorkOS outage shows no notice. */ +export const v2Moved = ( + source: V2MovedSource, + organizationId: string, + v2Origin: URL, +): Effect.Effect => + source.organizationMetadata(organizationId).pipe( + Effect.map((metadata) => parseV2Moved(metadata, v2Origin)), + Effect.orElseSucceed(() => null), + Effect.withSpan("v2Moved.read"), + ); + +/** The organization's metadata, read live from WorkOS (where v2 writes it). */ +export const liveV2MovedSource = Effect.gen(function* () { + const workos = yield* WorkOSClient; + return { + organizationMetadata: (organizationId) => + workos + .getOrganization(organizationId) + .pipe(Effect.map((organization) => organization.metadata)), + } satisfies V2MovedSource; +}); diff --git a/apps/cloud/src/web/components/v2-import-link.tsx b/apps/cloud/src/web/components/v2-import-link.tsx index e8d9e0683b..b53668339c 100644 --- a/apps/cloud/src/web/components/v2-import-link.tsx +++ b/apps/cloud/src/web/components/v2-import-link.tsx @@ -8,6 +8,9 @@ import { v2ImportAtom } from "../auth"; // decides who sees it (`auth/v2-import.ts`) and answers with the page URL only // for users v2 would let import the selected org; everyone else, and every // failure, renders nothing. Opens in a new tab, like the sidebar's Docs link. +// +// Once the org has moved, the link opens the org in v2 instead: the moved +// notice can be snoozed, and this stays the way back to v2. // --------------------------------------------------------------------------- function MoveIcon({ className }: { className?: string }) { @@ -26,17 +29,29 @@ function MoveIcon({ className }: { className?: string }) { export function V2ImportLink() { const result = useAtomValue(v2ImportAtom); - if (!AsyncResult.isSuccess(result) || result.value.url === null) return null; + if (!AsyncResult.isSuccess(result)) return null; + const { url, moved } = result.value; + const link = + moved !== null + ? { + href: moved.v2Url, + label: "Open in Executor v2", + track: () => trackEvent("v2_moved_open_clicked", { via: "sidebar" }), + } + : url !== null + ? { href: url, label: "Move to Executor v2", track: () => trackEvent("v2_import_opened") } + : null; + if (link === null) return null; return ( trackEvent("v2_import_opened")} + onClick={link.track} className="flex w-full items-center gap-2.5 rounded-md px-2.5 py-1.5 text-sm text-sidebar-foreground transition-colors hover:bg-sidebar-active/60 hover:text-foreground" > - Move to Executor v2 + {link.label} ); } diff --git a/apps/cloud/src/web/components/v2-moved-notice.tsx b/apps/cloud/src/web/components/v2-moved-notice.tsx new file mode 100644 index 0000000000..2da79ed59f --- /dev/null +++ b/apps/cloud/src/web/components/v2-moved-notice.tsx @@ -0,0 +1,205 @@ +import { useEffect, useId, useRef, useState } from "react"; +import { useAtomValue } from "@effect/atom-react"; +import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; +import { trackEvent } from "@executor-js/react/api/analytics"; +import { Button } from "@executor-js/react/components/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@executor-js/react/components/dialog"; +import { Label } from "@executor-js/react/components/label"; +import { Textarea } from "@executor-js/react/components/textarea"; +import { useAuth, v2ImportAtom } from "../auth"; +import { + browserSnoozeStorage, + isV2MovedSnoozed, + snoozeV2Moved, + V2_MOVED_DISMISS_SNOOZE_MS, + V2_MOVED_FEEDBACK_SNOOZE_MS, +} from "../v2-moved-snooze"; + +// --------------------------------------------------------------------------- +// Cloud-only notice for an organization v2 has already imported (the server +// reads the move from the WorkOS organization, `auth/v2-moved.ts`). It opens +// once per dashboard load unless snoozed for this org in this browser: +// - ✕ / Escape / opening v2 hidden for 7 days, nothing asked +// - "Keep using v1" step 2 asks why; sending hides it for 30 days +// v1 keeps working for a moved org; this only tells the user where it went. +// --------------------------------------------------------------------------- + +type Step = "notice" | "feedback"; + +const FEEDBACK_MAX_LENGTH = 2000; + +const movedDate = (movedAt: string): string => + new Intl.DateTimeFormat(undefined, { month: "short", day: "numeric", year: "numeric" }).format( + new Date(movedAt), + ); + +function ArrowIcon() { + return ( + + + + ); +} + +export function V2MovedNotice() { + const auth = useAuth(); + const result = useAtomValue(v2ImportAtom); + const moved = AsyncResult.isSuccess(result) ? result.value.moved : null; + const organization = auth.status === "authenticated" ? auth.organization : null; + const organizationId = organization?.id ?? null; + + const [open, setOpen] = useState(false); + const [step, setStep] = useState("notice"); + const [feedback, setFeedback] = useState(""); + const shownFor = useRef(null); + const feedbackId = useId(); + + // Decide once per org per page load, after hydration: the snooze lives in + // localStorage, which the server render cannot see. + useEffect(() => { + if (moved === null || organizationId === null) return; + if (shownFor.current === organizationId) return; + shownFor.current = organizationId; + if (isV2MovedSnoozed(browserSnoozeStorage(), organizationId, Date.now())) return; + setStep("notice"); + setFeedback(""); + setOpen(true); + trackEvent("v2_moved_modal_shown"); + }, [moved, organizationId]); + + if (moved === null || organization === null) return null; + + const close = (snoozeMs: number) => { + snoozeV2Moved(browserSnoozeStorage(), organization.id, Date.now(), snoozeMs); + setOpen(false); + }; + + const trimmed = feedback.trim(); + + return ( + { + if (next) return; + trackEvent("v2_moved_dismissed", { step }); + close(V2_MOVED_DISMISS_SNOOZE_MS); + }} + > + + {step === "notice" ? ( + <> + + + Moved {movedDate(moved.movedAt)} + + + {organization.name} now lives in Executor v2 + + + +
+ + v1 · {organization.name} + + + → + + + v2 · {moved.v2Slug} + +
+ + + Changes made here won't sync to v2. + + + + + + + + ) : ( +
{ + event.preventDefault(); + if (trimmed === "") return; + trackEvent("v2_moved_feedback_sent", { feedback: trimmed }); + close(V2_MOVED_FEEDBACK_SNOOZE_MS); + }} + > + + What went wrong? + + Tell us why you're staying on v1, so we can fix it. + + + +
+ +