History

Derive provider accounts in React

Rhys Sullivan committed 4 months ago

Commit 6c2fae51 parent 4bc231b5 files · +382 −1Browse files

Unified · Split

5 files
  1. packages/core/sdk/src/oauth-helpers.ts +3 −1
  2. packages/react/src/api/atoms.tsx +17 −0
  3. packages/react/src/lib/provider-accounts.test.ts +249 −0
  4. packages/react/src/lib/provider-accounts.ts +111 −0
  5. packages/react/src/plugins/oauth-sign-in.tsx +2 −0
packages/core/sdk/src/oauth-helpers.ts+3−1
@@ -183,7 +183,9 @@ export const providerAuthorizeExtras = ( · 182 unmodified lines
183183 loginHint?: string | null,
184184 ): Readonly<Record<string, string>> => {
185185 const trimmedLoginHint = loginHint?.trim();
186− const loginHintParam = trimmedLoginHint ? { login_hint: trimmedLoginHint } : {};
186+ const loginHintParam: Readonly<Record<string, string>> = trimmedLoginHint
187+ ? { login_hint: trimmedLoginHint }
188+ : {};
187189 // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: URL() throws on invalid input → no provider extras
188190 try {
189191 const host = new URL(authorizationUrl).hostname.toLowerCase();
packages/react/src/api/atoms.tsx+17−0
@@ -19,6 +19,7 @@ import * as Effect from "effect/Effect"; · 18 unmodified lines
1919
2020 import { ExecutorApiClient } from "./client";
2121 import { connectionWriteKeys, ReactivityKey } from "./reactivity-keys";
22+import { groupProviderAccounts } from "../lib/provider-accounts";
2223
2324 // ---------------------------------------------------------------------------
2425 // Query atoms — typed, cached, reactive. v2: owner-scoped (org | user) instead
@@ -119,6 +120,22 @@ export const connectionsAllAtom = ExecutorApiClient.query("connections", "list", · 94 unmodified lines
119120 reactivityKeys: [ReactivityKey.connections],
120121 });
121122
123+export const providerAccountsAtom = Atom.make(
124+ Effect.gen(function* () {
125+ const connections = yield* Atom.get(connectionsAllAtom);
126+ const integrations = yield* Atom.get(integrationsAtom);
127+ return AsyncResult.map(AsyncResult.all({ connections, integrations }), (value) => {
128+ const integrationsByKind = new Map(
129+ value.integrations.map((integration) => [String(integration.slug), integration]),
130+ );
131+ return groupProviderAccounts({
132+ connections: value.connections,
133+ integrationsByKind,
134+ });
135+ });
136+ }),
137+);
138+
122139 // ---------------------------------------------------------------------------
123140 // Providers — credential-backend discovery (new in v2).
124141 // ---------------------------------------------------------------------------
packages/react/src/lib/provider-accounts.test.ts+249−0
@@ -0,0 +1,249 @@
1+import { describe, expect, it } from "@effect/vitest";
2+
3+import { groupProviderAccounts, normalizeEmail } from "./provider-accounts";
4+
5+type TestConnection = {
6+ readonly owner: "org" | "user";
7+ readonly name: string;
8+ readonly integration: string;
9+ readonly identityLabel?: string | null;
10+};
11+
12+type TestIntegration = {
13+ readonly slug: string;
14+ readonly kind: string;
15+ readonly name: string;
16+};
17+
18+const integration = (slug: string, kind: string): TestIntegration => ({
19+ slug,
20+ kind,
21+ name: slug,
22+});
23+
24+const connection = (input: TestConnection): TestConnection => input;
25+
26+const integrations = new Map<string, TestIntegration>([
27+ ["google_calendar", integration("google_calendar", "google")],
28+ ["google_gmail", integration("google_gmail", "google")],
29+ ["microsoft_mail", integration("microsoft_mail", "microsoft")],
30+ ["microsoft_calendar", integration("microsoft_calendar", "microsoft")],
31+ ["github_issues", integration("github_issues", "github")],
32+ ["github_prs", integration("github_prs", "github")],
33+]);
34+
35+const memberships = (
36+ accounts: ReturnType<typeof groupProviderAccounts<TestConnection, TestIntegration>>,
37+) =>
38+ accounts.map((account) => ({
39+ identityKey: account.identityKey,
40+ connections: account.connections.map((entry) => entry.connection.name),
41+ }));
42+
43+describe("normalizeEmail", () => {
44+ it("trims, lowercases, and rejects non-email labels", () => {
45+ expect(normalizeEmail(" User@Example.COM ")).toBe("user@example.com");
46+ expect(normalizeEmail("not an email")).toBeNull();
47+ expect(normalizeEmail(null)).toBeNull();
48+ });
49+});
50+
51+describe("groupProviderAccounts", () => {
52+ it("groups Google services with the same email into one account", () => {
53+ const accounts = groupProviderAccounts({
54+ integrationsByKind: integrations,
55+ connections: [
56+ connection({
57+ owner: "user",
58+ name: "gmail",
59+ integration: "google_gmail",
60+ identityLabel: "User@Example.com",
61+ }),
62+ connection({
63+ owner: "user",
64+ name: "calendar",
65+ integration: "google_calendar",
66+ identityLabel: " user@example.com ",
67+ }),
68+ ],
69+ });
70+
71+ expect(accounts).toHaveLength(1);
72+ expect(accounts[0]?.identityKey).toBe("user:google:user@example.com");
73+ expect(accounts[0]?.label).toBe("user@example.com");
74+ expect(accounts[0]?.connections.map((entry) => entry.connection.name)).toEqual([
75+ "calendar",
76+ "gmail",
77+ ]);
78+ });
79+
80+ it("keeps different Google emails as separate accounts", () => {
81+ const accounts = groupProviderAccounts({
82+ integrationsByKind: integrations,
83+ connections: [
84+ connection({
85+ owner: "user",
86+ name: "work",
87+ integration: "google_gmail",
88+ identityLabel: "work@example.com",
89+ }),
90+ connection({
91+ owner: "user",
92+ name: "personal",
93+ integration: "google_calendar",
94+ identityLabel: "me@example.com",
95+ }),
96+ ],
97+ });
98+
99+ expect(memberships(accounts)).toEqual([
100+ { identityKey: "user:google:me@example.com", connections: ["personal"] },
101+ { identityKey: "user:google:work@example.com", connections: ["work"] },
102+ ]);
103+ });
104+
105+ it("keeps Microsoft in a separate family from Google", () => {
106+ const accounts = groupProviderAccounts({
107+ integrationsByKind: integrations,
108+ connections: [
109+ connection({
110+ owner: "user",
111+ name: "google",
112+ integration: "google_gmail",
113+ identityLabel: "user@example.com",
114+ }),
115+ connection({
116+ owner: "user",
117+ name: "microsoft",
118+ integration: "microsoft_mail",
119+ identityLabel: "user@example.com",
120+ }),
121+ ],
122+ });
123+
124+ expect(memberships(accounts)).toEqual([
125+ { identityKey: "user:google:user@example.com", connections: ["google"] },
126+ { identityKey: "user:microsoft:user@example.com", connections: ["microsoft"] },
127+ ]);
128+ });
129+
130+ it("does not merge non-family integrations", () => {
131+ const accounts = groupProviderAccounts({
132+ integrationsByKind: integrations,
133+ connections: [
134+ connection({
135+ owner: "user",
136+ name: "issues",
137+ integration: "github_issues",
138+ identityLabel: "user@example.com",
139+ }),
140+ connection({
141+ owner: "user",
142+ name: "prs",
143+ integration: "github_prs",
144+ identityLabel: "user@example.com",
145+ }),
146+ ],
147+ });
148+
149+ expect(memberships(accounts)).toEqual([
150+ { identityKey: "user:github:github_issues:issues", connections: ["issues"] },
151+ { identityKey: "user:github:github_prs:prs", connections: ["prs"] },
152+ ]);
153+ });
154+
155+ it("keeps organization and user owners separate", () => {
156+ const accounts = groupProviderAccounts({
157+ integrationsByKind: integrations,
158+ connections: [
159+ connection({
160+ owner: "org",
161+ name: "workspace",
162+ integration: "google_gmail",
163+ identityLabel: "user@example.com",
164+ }),
165+ connection({
166+ owner: "user",
167+ name: "personal",
168+ integration: "google_calendar",
169+ identityLabel: "user@example.com",
170+ }),
171+ ],
172+ });
173+
174+ expect(memberships(accounts)).toEqual([
175+ { identityKey: "org:google:user@example.com", connections: ["workspace"] },
176+ { identityKey: "user:google:user@example.com", connections: ["personal"] },
177+ ]);
178+ });
179+
180+ it("keeps null or non-email identity labels as singleton accounts", () => {
181+ const accounts = groupProviderAccounts({
182+ integrationsByKind: integrations,
183+ connections: [
184+ connection({
185+ owner: "user",
186+ name: "gmail",
187+ integration: "google_gmail",
188+ identityLabel: null,
189+ }),
190+ connection({
191+ owner: "user",
192+ name: "calendar",
193+ integration: "google_calendar",
194+ identityLabel: "Personal Google",
195+ }),
196+ ],
197+ });
198+
199+ expect(memberships(accounts)).toEqual([
200+ { identityKey: "user:google:google_calendar:calendar", connections: ["calendar"] },
201+ { identityKey: "user:google:google_gmail:gmail", connections: ["gmail"] },
202+ ]);
203+ });
204+
205+ it("regroups deterministically when a label changes to a matching email", () => {
206+ const before = groupProviderAccounts({
207+ integrationsByKind: integrations,
208+ connections: [
209+ connection({
210+ owner: "user",
211+ name: "calendar",
212+ integration: "google_calendar",
213+ identityLabel: "old@example.com",
214+ }),
215+ connection({
216+ owner: "user",
217+ name: "gmail",
218+ integration: "google_gmail",
219+ identityLabel: "user@example.com",
220+ }),
221+ ],
222+ });
223+ const after = groupProviderAccounts({
224+ integrationsByKind: integrations,
225+ connections: [
226+ connection({
227+ owner: "user",
228+ name: "calendar",
229+ integration: "google_calendar",
230+ identityLabel: " USER@example.com ",
231+ }),
232+ connection({
233+ owner: "user",
234+ name: "gmail",
235+ integration: "google_gmail",
236+ identityLabel: "user@example.com",
237+ }),
238+ ],
239+ });
240+
241+ expect(memberships(before)).toEqual([
242+ { identityKey: "user:google:old@example.com", connections: ["calendar"] },
243+ { identityKey: "user:google:user@example.com", connections: ["gmail"] },
244+ ]);
245+ expect(memberships(after)).toEqual([
246+ { identityKey: "user:google:user@example.com", connections: ["calendar", "gmail"] },
247+ ]);
248+ });
249+});
packages/react/src/lib/provider-accounts.ts+111−0
@@ -0,0 +1,111 @@
1+export type ProviderAccountConnectionLike = {
2+ readonly owner: string;
3+ readonly name: unknown;
4+ readonly integration: unknown;
5+ readonly identityLabel?: string | null;
6+};
7+
8+export type ProviderAccountIntegrationLike = {
9+ readonly slug: unknown;
10+ readonly kind: string;
11+};
12+
13+export type ProviderAccountConnection<
14+ TConnection extends ProviderAccountConnectionLike = ProviderAccountConnectionLike,
15+ TIntegration extends ProviderAccountIntegrationLike = ProviderAccountIntegrationLike,
16+> = {
17+ readonly connection: TConnection;
18+ readonly integration: TIntegration;
19+};
20+
21+export type ProviderAccount<
22+ TConnection extends ProviderAccountConnectionLike = ProviderAccountConnectionLike,
23+ TIntegration extends ProviderAccountIntegrationLike = ProviderAccountIntegrationLike,
24+> = {
25+ readonly family: string;
26+ readonly owner: TConnection["owner"];
27+ readonly identityKey: string;
28+ readonly label: string;
29+ readonly connections: readonly ProviderAccountConnection<TConnection, TIntegration>[];
30+};
31+
32+export const MULTI_SERVICE_FAMILIES: ReadonlySet<string> = new Set(["google", "microsoft"]);
33+
34+const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
35+
36+export const normalizeEmail = (value: string | null | undefined): string | null => {
37+ const normalized = value?.trim().toLowerCase() ?? "";
38+ return EMAIL_PATTERN.test(normalized) ? normalized : null;
39+};
40+
41+const singletonIdentityKey = (connection: ProviderAccountConnectionLike, family: string): string =>
42+ `${String(connection.owner)}:${family}:${String(connection.integration)}:${String(
43+ connection.name,
44+ )}`;
45+
46+const displayLabelFor = (connection: ProviderAccountConnectionLike): string => {
47+ const label = connection.identityLabel?.trim();
48+ return label && label.length > 0 ? label : String(connection.name);
49+};
50+
51+export function groupProviderAccounts<
52+ TConnection extends ProviderAccountConnectionLike,
53+ TIntegration extends ProviderAccountIntegrationLike,
54+>(input: {
55+ readonly connections: readonly TConnection[];
56+ readonly integrationsByKind: ReadonlyMap<string, TIntegration>;
57+ readonly families?: ReadonlySet<string>;
58+}): readonly ProviderAccount<TConnection, TIntegration>[] {
59+ const families = input.families ?? MULTI_SERVICE_FAMILIES;
60+ const groups = new Map<string, ProviderAccount<TConnection, TIntegration>>();
61+
62+ for (const connection of input.connections) {
63+ const integration = input.integrationsByKind.get(String(connection.integration));
64+ if (!integration) continue;
65+
66+ const family = integration.kind;
67+ const email = normalizeEmail(connection.identityLabel);
68+ const canMerge = families.has(family) && email !== null;
69+ const identityKey = canMerge
70+ ? `${String(connection.owner)}:${family}:${email}`
71+ : singletonIdentityKey(connection, family);
72+ const label = canMerge ? email : displayLabelFor(connection);
73+ const existing = groups.get(identityKey);
74+ const entry = { connection, integration };
75+
76+ if (existing) {
77+ groups.set(identityKey, {
78+ ...existing,
79+ connections: [...existing.connections, entry],
80+ });
81+ continue;
82+ }
83+
84+ groups.set(identityKey, {
85+ family,
86+ owner: connection.owner,
87+ identityKey,
88+ label,
89+ connections: [entry],
90+ });
91+ }
92+
93+ return Array.from(groups.values())
94+ .map((account) => ({
95+ ...account,
96+ connections: [...account.connections].sort((a, b) => {
97+ const integrationOrder = String(a.integration.slug).localeCompare(
98+ String(b.integration.slug),
99+ );
100+ if (integrationOrder !== 0) return integrationOrder;
101+ return String(a.connection.name).localeCompare(String(b.connection.name));
102+ }),
103+ }))
104+ .sort((a, b) => {
105+ const ownerOrder = String(a.owner).localeCompare(String(b.owner));
106+ if (ownerOrder !== 0) return ownerOrder;
107+ const familyOrder = a.family.localeCompare(b.family);
108+ if (familyOrder !== 0) return familyOrder;
109+ return a.identityKey.localeCompare(b.identityKey);
110+ });
111+}
packages/react/src/plugins/oauth-sign-in.tsx+2−0
@@ -69,6 +69,7 @@ export type OAuthStartPayload = { · 68 unmodified lines
6969 readonly integration: IntegrationSlug;
7070 readonly template: AuthTemplateSlug;
7171 readonly identityLabel?: string;
72+ readonly loginHint?: string;
7273 readonly redirectUri?: string;
7374 };
7475
@@ -381,6 +382,7 @@ export function useOAuthPopupFlow< · 306 unmodified lines
381382 integration: input.payload.integration,
382383 template: input.payload.template,
383384 identityLabel: input.payload.identityLabel,
385+ loginHint: input.payload.loginHint,
384386 redirectUri: input.payload.redirectUri ?? oauthCallbackUrl(callbackPath),
385387 },
386388 }).then((exit) =>

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t