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
183 loginHint?: string | null,183 loginHint?: string | null,
184): Readonly<Record<string, string>> => {184): Readonly<Record<string, string>> => {
185 const trimmedLoginHint = loginHint?.trim();185 const trimmedLoginHint = loginHint?.trim();
186 const loginHintParam = trimmedLoginHint ? { login_hint: trimmedLoginHint } : {};186 const loginHintParam: Readonly<Record<string, string>> = trimmedLoginHint
187 ? { login_hint: trimmedLoginHint }
188 : {};
187 // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: URL() throws on invalid input → no provider extras189 // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: URL() throws on invalid input → no provider extras
188 try {190 try {
189 const host = new URL(authorizationUrl).hostname.toLowerCase();191 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
19 19
20import { ExecutorApiClient } from "./client";20import { ExecutorApiClient } from "./client";
21import { connectionWriteKeys, ReactivityKey } from "./reactivity-keys";21import { connectionWriteKeys, ReactivityKey } from "./reactivity-keys";
22import { groupProviderAccounts } from "../lib/provider-accounts";
22 23
23// ---------------------------------------------------------------------------24// ---------------------------------------------------------------------------
24// Query atoms — typed, cached, reactive. v2: owner-scoped (org | user) instead25// 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
119 reactivityKeys: [ReactivityKey.connections],120 reactivityKeys: [ReactivityKey.connections],
120});121});
121 122
123export 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
122// ---------------------------------------------------------------------------139// ---------------------------------------------------------------------------
123// Providers — credential-backend discovery (new in v2).140// Providers — credential-backend discovery (new in v2).
124// ---------------------------------------------------------------------------141// ---------------------------------------------------------------------------
packages/react/src/lib/provider-accounts.test.ts+249−0
@@ -0,0 +1,249 @@
1import { describe, expect, it } from "@effect/vitest";
2
3import { groupProviderAccounts, normalizeEmail } from "./provider-accounts";
4
5type TestConnection = {
6 readonly owner: "org" | "user";
7 readonly name: string;
8 readonly integration: string;
9 readonly identityLabel?: string | null;
10};
11
12type TestIntegration = {
13 readonly slug: string;
14 readonly kind: string;
15 readonly name: string;
16};
17
18const integration = (slug: string, kind: string): TestIntegration => ({
19 slug,
20 kind,
21 name: slug,
22});
23
24const connection = (input: TestConnection): TestConnection => input;
25
26const 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
35const 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
43describe("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
51describe("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 @@
1export type ProviderAccountConnectionLike = {
2 readonly owner: string;
3 readonly name: unknown;
4 readonly integration: unknown;
5 readonly identityLabel?: string | null;
6};
7
8export type ProviderAccountIntegrationLike = {
9 readonly slug: unknown;
10 readonly kind: string;
11};
12
13export type ProviderAccountConnection<
14 TConnection extends ProviderAccountConnectionLike = ProviderAccountConnectionLike,
15 TIntegration extends ProviderAccountIntegrationLike = ProviderAccountIntegrationLike,
16> = {
17 readonly connection: TConnection;
18 readonly integration: TIntegration;
19};
20
21export 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
32export const MULTI_SERVICE_FAMILIES: ReadonlySet<string> = new Set(["google", "microsoft"]);
33
34const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
35
36export const normalizeEmail = (value: string | null | undefined): string | null => {
37 const normalized = value?.trim().toLowerCase() ?? "";
38 return EMAIL_PATTERN.test(normalized) ? normalized : null;
39};
40
41const singletonIdentityKey = (connection: ProviderAccountConnectionLike, family: string): string =>
42 `${String(connection.owner)}:${family}:${String(connection.integration)}:${String(
43 connection.name,
44 )}`;
45
46const displayLabelFor = (connection: ProviderAccountConnectionLike): string => {
47 const label = connection.identityLabel?.trim();
48 return label && label.length > 0 ? label : String(connection.name);
49};
50
51export 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
69 readonly integration: IntegrationSlug;69 readonly integration: IntegrationSlug;
70 readonly template: AuthTemplateSlug;70 readonly template: AuthTemplateSlug;
71 readonly identityLabel?: string;71 readonly identityLabel?: string;
72 readonly loginHint?: string;
72 readonly redirectUri?: string;73 readonly redirectUri?: string;
73};74};
74 75
@@ -381,6 +382,7 @@ export function useOAuthPopupFlow< · 306 unmodified lines
381 integration: input.payload.integration,382 integration: input.payload.integration,
382 template: input.payload.template,383 template: input.payload.template,
383 identityLabel: input.payload.identityLabel,384 identityLabel: input.payload.identityLabel,
385 loginHint: input.payload.loginHint,
384 redirectUri: input.payload.redirectUri ?? oauthCallbackUrl(callbackPath),386 redirectUri: input.payload.redirectUri ?? oauthCallbackUrl(callbackPath),
385 },387 },
386 }).then((exit) =>388 }).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