Unified · Split
| @@ -183,7 +183,9 @@ export const providerAuthorizeExtras = ( · 182 unmodified lines | ||
| 183 | 183 | loginHint?: string | null, |
| 184 | 184 | ): Readonly<Record<string, string>> => { |
| 185 | 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 | 189 | // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: URL() throws on invalid input → no provider extras |
| 188 | 190 | try { |
| 189 | 191 | const host = new URL(authorizationUrl).hostname.toLowerCase(); |
| @@ -19,6 +19,7 @@ import * as Effect from "effect/Effect"; · 18 unmodified lines | ||
| 19 | 19 | |
| 20 | 20 | import { ExecutorApiClient } from "./client"; |
| 21 | 21 | import { connectionWriteKeys, ReactivityKey } from "./reactivity-keys"; |
| 22 | +import { groupProviderAccounts } from "../lib/provider-accounts"; | |
| 22 | 23 | |
| 23 | 24 | // --------------------------------------------------------------------------- |
| 24 | 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 | 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 | 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 | 69 | readonly integration: IntegrationSlug; |
| 70 | 70 | readonly template: AuthTemplateSlug; |
| 71 | 71 | readonly identityLabel?: string; |
| 72 | + readonly loginHint?: string; | |
| 72 | 73 | readonly redirectUri?: string; |
| 73 | 74 | }; |
| 74 | 75 | |
| @@ -381,6 +382,7 @@ export function useOAuthPopupFlow< · 306 unmodified lines | ||
| 381 | 382 | integration: input.payload.integration, |
| 382 | 383 | template: input.payload.template, |
| 383 | 384 | identityLabel: input.payload.identityLabel, |
| 385 | + loginHint: input.payload.loginHint, | |
| 384 | 386 | redirectUri: input.payload.redirectUri ?? oauthCallbackUrl(callbackPath), |
| 385 | 387 | }, |
| 386 | 388 | }).then((exit) => |