Skip to content

Commit a9150be

Browse files
committed
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.
1 parent 9190d4d commit a9150be

10 files changed

Lines changed: 462 additions & 22 deletions

File tree

‎.changeset/v2-moved-notice.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@executor-js/cloud": patch
3+
---
4+
5+
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.

‎apps/cloud/src/auth/api.ts‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,19 @@ const AuthOrganizationsResponse = Schema.Struct({
3737
// Executor v2's workspace import for the selected org: its page URL when v2
3838
// would let the signed-in user import the org, otherwise null. Nothing else
3939
// about the membership or billing reads behind it is returned.
40+
//
41+
// `moved` is set instead when the org has already been imported (v2 records
42+
// the move on the WorkOS organization): where it lives in v2 and when it
43+
// moved. It drives a notice only; the org keeps working in v1.
44+
const V2MovedResponse = Schema.Struct({
45+
v2Url: Schema.String,
46+
v2Slug: Schema.String,
47+
movedAt: Schema.String,
48+
});
49+
4050
const V2ImportResponse = Schema.Struct({
4151
url: Schema.NullOr(Schema.String),
52+
moved: Schema.NullOr(V2MovedResponse),
4253
});
4354

4455
const CreateOrganizationBody = Schema.Struct({

‎apps/cloud/src/auth/handlers.ts‎

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,8 @@ import {
4242
type AuthorizeOrganizationOptions,
4343
} from "./organization";
4444
import { mcpSessionStub } from "@executor-js/cloudflare/mcp/session-stub";
45-
import { liveV2ImportSource, v2ImportEligible, v2ImportUrl } from "./v2-import";
45+
import { liveV2ImportSource, v2ImportAnswer } from "./v2-import";
46+
import { liveV2MovedSource } from "./v2-moved";
4647

4748
const COOKIE_OPTIONS = {
4849
path: "/",
@@ -443,14 +444,13 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group(
443444
.handle("v2Import", () =>
444445
Effect.gen(function* () {
445446
const session = yield* requireSelectedOrganization;
446-
const url = v2ImportUrl(env);
447-
if (url === null) return { url: null };
448-
const eligible = yield* v2ImportEligible(
449-
yield* liveV2ImportSource,
450-
session.accountId,
451-
session.organizationId,
452-
);
453-
return { url: eligible ? url.href : null };
447+
return yield* v2ImportAnswer({
448+
env,
449+
importSource: liveV2ImportSource,
450+
movedSource: liveV2MovedSource,
451+
userId: session.accountId,
452+
organizationId: session.organizationId,
453+
});
454454
}),
455455
)
456456
.handle("createOrganization", ({ payload }) =>

‎apps/cloud/src/auth/v2-import.ts‎

Lines changed: 45 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,21 +15,27 @@ import { Effect } from "effect";
1515

1616
import { parseV2Edge, type V2EdgeEnv } from "../edge/marketing";
1717
import { AutumnService, type AutumnSubscription } from "../extensions/billing/service";
18+
import { v2Moved, type V2Moved, type V2MovedSource } from "./v2-moved";
1819
import { WorkOSClient } from "./workos";
1920

2021
/** v2's import page, on v2's app host. */
2122
const V2_IMPORT_PATH = "/v1-import";
2223

2324
/**
24-
* The absolute URL of v2's import page, or `null` where v1 does not hand
25-
* requests to v2 (local dev without the v2 settings, test workers, or broken
26-
* settings). v2's app host is the origin of its sign-up page, the same host
27-
* the edge sends v2 dashboard links to.
25+
* v2's app host, or `null` where v1 does not hand requests to v2 (local dev
26+
* without the v2 settings, test workers, or broken settings). It is the origin
27+
* of v2's sign-up page, the same host the edge sends v2 dashboard links to.
2828
*/
29-
export const v2ImportUrl = (env: V2EdgeEnv): URL | null => {
29+
export const v2AppOrigin = (env: V2EdgeEnv): URL | null => {
3030
const edge = parseV2Edge(env);
3131
if (edge === null || typeof edge === "string") return null;
32-
return new URL(V2_IMPORT_PATH, edge.signUpUrl.origin);
32+
return new URL(edge.signUpUrl.origin);
33+
};
34+
35+
/** The absolute URL of v2's import page, or `null` where v1 has no v2 host. */
36+
export const v2ImportUrl = (env: V2EdgeEnv): URL | null => {
37+
const origin = v2AppOrigin(env);
38+
return origin === null ? null : new URL(V2_IMPORT_PATH, origin);
3339
};
3440

3541
/** v1's plans that cost nothing, as v2's import counts them (v2's
@@ -103,3 +109,36 @@ export const liveV2ImportSource = Effect.gen(function* () {
103109
.pipe(Effect.map((customer) => customer?.subscriptions ?? [])),
104110
} satisfies V2ImportSource;
105111
});
112+
113+
/** What the dashboard shows for the selected org: the import link, or where
114+
* the org now lives in v2. At most one is set. */
115+
export interface V2ImportAnswer {
116+
readonly url: string | null;
117+
readonly moved: V2Moved | null;
118+
}
119+
120+
/**
121+
* The `GET /api/auth/v2-import` answer. Without a v2 host there is nothing to
122+
* link to. An org that has already moved is not offered the import again, so
123+
* its eligibility is never read. Never fails.
124+
*/
125+
export const v2ImportAnswer = <RImport, RMoved>(input: {
126+
readonly env: V2EdgeEnv;
127+
readonly importSource: Effect.Effect<V2ImportSource, never, RImport>;
128+
readonly movedSource: Effect.Effect<V2MovedSource, never, RMoved>;
129+
readonly userId: string;
130+
readonly organizationId: string;
131+
}): Effect.Effect<V2ImportAnswer, never, RImport | RMoved> =>
132+
Effect.gen(function* () {
133+
const v2Origin = v2AppOrigin(input.env);
134+
const importUrl = v2ImportUrl(input.env);
135+
if (v2Origin === null || importUrl === null) return { url: null, moved: null };
136+
const moved = yield* v2Moved(yield* input.movedSource, input.organizationId, v2Origin);
137+
if (moved !== null) return { url: null, moved };
138+
const eligible = yield* v2ImportEligible(
139+
yield* input.importSource,
140+
input.userId,
141+
input.organizationId,
142+
);
143+
return { url: eligible ? importUrl.href : null, moved: null };
144+
});

‎apps/cloud/src/auth/v2-moved.ts‎

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
// ---------------------------------------------------------------------------
2+
// Whether the selected organization has moved to Executor v2.
3+
//
4+
// When a user finishes importing a v1 organization, v2 writes two keys onto
5+
// the v1 WorkOS organization's metadata:
6+
// executor_v2_org_slug the v2 organization's slug (its `/org/<slug>` URL)
7+
// executor_v2_moved_at when the import finished, as an ISO 8601 timestamp
8+
// Both must be present and well formed; anything else, and every failure to
9+
// read the organization, answers "not moved". The answer only drives a notice:
10+
// a moved organization keeps working in v1.
11+
// ---------------------------------------------------------------------------
12+
13+
import { Effect } from "effect";
14+
15+
import { WorkOSClient } from "./workos";
16+
17+
export const V2_MOVED_SLUG_KEY = "executor_v2_org_slug";
18+
export const V2_MOVED_AT_KEY = "executor_v2_moved_at";
19+
20+
/** v2's organization slug rule (`OrganizationSlug` in v2's hosted contracts). */
21+
const V2_SLUG = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
22+
const V2_SLUG_MAX_LENGTH = 45;
23+
24+
/** An ISO 8601 date and time with an explicit offset, as `Date#toISOString`
25+
* writes it. A bare date or a local time is ambiguous, so it is refused. */
26+
const ISO_TIMESTAMP = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:\d{2})$/;
27+
28+
export interface V2Moved {
29+
/** The v2 organization's page: `<v2 origin>/org/<slug>`. */
30+
readonly v2Url: string;
31+
readonly v2Slug: string;
32+
/** When the import finished, normalized to `Date#toISOString`. */
33+
readonly movedAt: string;
34+
}
35+
36+
/** The move recorded in an organization's metadata, or `null` when there is
37+
* none or it is malformed. */
38+
export const parseV2Moved = (
39+
metadata: Readonly<Record<string, unknown>>,
40+
v2Origin: URL,
41+
): V2Moved | null => {
42+
const slug = metadata[V2_MOVED_SLUG_KEY];
43+
const movedAt = metadata[V2_MOVED_AT_KEY];
44+
if (typeof slug !== "string" || slug.length > V2_SLUG_MAX_LENGTH || !V2_SLUG.test(slug)) {
45+
return null;
46+
}
47+
if (typeof movedAt !== "string" || !ISO_TIMESTAMP.test(movedAt)) return null;
48+
const time = Date.parse(movedAt);
49+
if (!Number.isFinite(time)) return null;
50+
return {
51+
v2Url: new URL(`/org/${slug}`, v2Origin).href,
52+
v2Slug: slug,
53+
movedAt: new Date(time).toISOString(),
54+
};
55+
};
56+
57+
/** The read the check makes. */
58+
export interface V2MovedSource {
59+
readonly organizationMetadata: (
60+
organizationId: string,
61+
) => Effect.Effect<Readonly<Record<string, unknown>>, unknown>;
62+
}
63+
64+
/** Whether the organization has moved to v2. Never fails: an unavailable
65+
* read answers `null`, so a WorkOS outage shows no notice. */
66+
export const v2Moved = (
67+
source: V2MovedSource,
68+
organizationId: string,
69+
v2Origin: URL,
70+
): Effect.Effect<V2Moved | null> =>
71+
source.organizationMetadata(organizationId).pipe(
72+
Effect.map((metadata) => parseV2Moved(metadata, v2Origin)),
73+
Effect.orElseSucceed(() => null),
74+
Effect.withSpan("v2Moved.read"),
75+
);
76+
77+
/** The organization's metadata, read live from WorkOS (where v2 writes it). */
78+
export const liveV2MovedSource = Effect.gen(function* () {
79+
const workos = yield* WorkOSClient;
80+
return {
81+
organizationMetadata: (organizationId) =>
82+
workos
83+
.getOrganization(organizationId)
84+
.pipe(Effect.map((organization) => organization.metadata)),
85+
} satisfies V2MovedSource;
86+
});

‎apps/cloud/src/web/components/v2-import-link.tsx‎

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@ import { v2ImportAtom } from "../auth";
88
// decides who sees it (`auth/v2-import.ts`) and answers with the page URL only
99
// for users v2 would let import the selected org; everyone else, and every
1010
// failure, renders nothing. Opens in a new tab, like the sidebar's Docs link.
11+
//
12+
// Once the org has moved, the link opens the org in v2 instead: the moved
13+
// notice can be snoozed, and this stays the way back to v2.
1114
// ---------------------------------------------------------------------------
1215

1316
function MoveIcon({ className }: { className?: string }) {
@@ -26,17 +29,29 @@ function MoveIcon({ className }: { className?: string }) {
2629

2730
export function V2ImportLink() {
2831
const result = useAtomValue(v2ImportAtom);
29-
if (!AsyncResult.isSuccess(result) || result.value.url === null) return null;
32+
if (!AsyncResult.isSuccess(result)) return null;
33+
const { url, moved } = result.value;
34+
const link =
35+
moved !== null
36+
? {
37+
href: moved.v2Url,
38+
label: "Open in Executor v2",
39+
track: () => trackEvent("v2_moved_open_clicked", { via: "sidebar" }),
40+
}
41+
: url !== null
42+
? { href: url, label: "Move to Executor v2", track: () => trackEvent("v2_import_opened") }
43+
: null;
44+
if (link === null) return null;
3045
return (
3146
<a
32-
href={result.value.url}
47+
href={link.href}
3348
target="_blank"
3449
rel="noopener noreferrer"
35-
onClick={() => trackEvent("v2_import_opened")}
50+
onClick={link.track}
3651
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"
3752
>
3853
<MoveIcon className="size-3.5 text-muted-foreground" />
39-
Move to Executor v2
54+
{link.label}
4055
</a>
4156
);
4257
}

0 commit comments

Comments
 (0)