Unified · Split
| @@ -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 extras | 189 | // 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(); |
| @@ -19,6 +19,7 @@ import * as Effect from "effect/Effect"; · 18 unmodified lines | |||
| 19 | 19 | ||
| 20 | import { ExecutorApiClient } from "./client"; | 20 | import { ExecutorApiClient } from "./client"; |
| 21 | import { connectionWriteKeys, ReactivityKey } from "./reactivity-keys"; | 21 | import { connectionWriteKeys, ReactivityKey } from "./reactivity-keys"; |
| 22 | import { groupProviderAccounts } from "../lib/provider-accounts"; | ||
| 22 | 23 | ||
| 23 | // --------------------------------------------------------------------------- | 24 | // --------------------------------------------------------------------------- |
| 24 | // Query atoms — typed, cached, reactive. v2: owner-scoped (org | user) instead | 25 | // 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 | ||
| 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 | |||
| 122 | // --------------------------------------------------------------------------- | 139 | // --------------------------------------------------------------------------- |
| 123 | // Providers — credential-backend discovery (new in v2). | 140 | // Providers — credential-backend discovery (new in v2). |
| 124 | // --------------------------------------------------------------------------- | 141 | // --------------------------------------------------------------------------- |
| @@ -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 | }); | ||
| @@ -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 | } | ||
| @@ -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) => |