Allow multiple social accounts per network via env - #286
Merged
paulocastellano merged 49 commits intoAug 25, 2026
Conversation
SocialAccountObserver already bypasses the one-account-per-network guard when trypost.self_hosted is true, but the frontend had no way to know that and always collapsed a network to a single card once any account existed - so self-hosted deployments could not surface a second LinkedIn (or Instagram) connection even though the backend would allow creating it.
NetworkConnectGrid always collapsed a network (LinkedIn profile/page, Instagram standalone/Facebook) to a single card once any account existed, with no way to trigger another OAuth flow - even though SocialAccountObserver already allows unlimited accounts per network in self-hosted mode. A self-hoster connecting their personal LinkedIn profile had no path back to the connect flow to also add a company page (or a second company page/showcase page). Render one card per connected account instead of collapsing to the first, and keep a standing "Connect another" card available for a network's existing connections when self-hosted. Hosted mode is unchanged: still one card per network, matching the backend's still-enforced one-account-per-network limit there.
Backend behavior for connecting a second identity per network in self-hosted mode was already covered (LinkedInControllerTest, NetworkUniquenessTest) - these just confirm the new prop the frontend now depends on is actually present and reflects config correctly.
Pre-existing drift in this file unrelated to the selfHosted change.
There was a problem hiding this comment.
Pull request overview
This PR unblocks self-hosted workspaces from connecting multiple social accounts per network by teaching the frontend to keep a “connect another” entry point available (while keeping hosted/SaaS behavior aligned with the existing backend limit).
Changes:
- Pass a
selfHostedboolean prop fromSocialController::index()andOnboardingController::index()to drive UI behavior. - Update
NetworkConnectGrid.vueto render one card per connected account and (self-hosted only) keep an additional “Connect another” card available per network. - Add backend feature tests for the new
selfHostedInertia prop and introduce an English translation key for the new CTA.
Reviewed changes
Copilot reviewed 8 out of 8 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
app/Http/Controllers/Auth/SocialController.php |
Adds selfHosted prop to accounts index Inertia payload. |
app/Http/Controllers/App/OnboardingController.php |
Adds selfHosted prop (lazy) to onboarding Inertia payload. |
resources/js/components/accounts/NetworkConnectGrid.vue |
Changes card rendering to support multiple connected accounts and self-hosted “connect another” CTA. |
resources/js/pages/accounts/Index.vue |
Wires selfHosted prop into the accounts page and passes it to the grid. |
resources/js/pages/onboarding/Index.vue |
Wires selfHosted prop into onboarding and passes it to the grid. |
lang/en/accounts.php |
Adds accounts.connect_another_cta translation key. |
tests/Feature/SocialControllerTest.php |
Adds hosted vs self-hosted assertions for the selfHosted prop on accounts index. |
tests/Feature/Onboarding/OnboardingControllerTest.php |
Updates onboarding inertia assertions to include selfHosted (but currently only asserts presence). |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
The flag is already shared by HandleInertiaRequests, so the accounts and onboarding controllers do not need to pass it again. Co-authored-by: Cursor <cursoragent@cursor.com>
Cloud cannot flip SELF_HOSTED, so one-per-network is now ALLOW_MULTIPLE_SOCIAL_ACCOUNTS (default false). Co-authored-by: Cursor <cursoragent@cursor.com>
Keep every connected identity visible, share occupiesNetwork, and return network_taken instead of a generic connect error. Co-authored-by: Cursor <cursoragent@cursor.com>
Reconnect now updates the selected account, and a unique index plus connectIdentity keep the same platform identity from being inserted twice. Co-authored-by: Cursor <cursoragent@cursor.com>
Keep the popup opener URL-only so reconnect query params are assembled at the call site. Co-authored-by: Cursor <cursoragent@cursor.com>
Skip migration cleanup that production never needs, trust the platform enum in the observer, and move card theming out of the grid. Co-authored-by: Cursor <cursoragent@cursor.com>
Drop dead instanceof/isset guards and filter reconnect targets in the query so a stale session cannot update another network's card. Co-authored-by: Cursor <cursoragent@cursor.com>
Index OAuth identities by id so reconnect and occupancy use only/except instead of hand-rolled filters. Co-authored-by: Cursor <cursoragent@cursor.com>
Drop the unused occupiesNetwork exception and persist reconnects with update() instead of fill/save/fresh. Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
phpunit.xml forced ALLOW_MULTIPLE_SOCIAL_ACCOUNTS=false, overriding the true value in .env.ci. That broke eight tests across Automation, MCP, PostApi, RefreshExpiringTokens and VerifyUpcomingConnections which only needed two accounts of one network as a fixture, not as a rule under test. Match .env.ci instead. Every test that exercises the one-per-network rule already sets the config itself; the accounts index test was the only one leaning on the implicit default, so it now pins it.
allow_multiple_social_accounts fell back to env('SELF_HOSTED', false)
while self_hosted itself defaults to env('SELF_HOSTED', true). A
self-hosted install that never wrote SELF_HOSTED to its .env resolved to
false and silently lost multiple accounts per network on upgrade, which
is the opposite of what the documented fallback promises.
Installs predating the index can hold the same identity twice: the network guard was bypassed for multi-account installs and Pinterest always created a fresh row. Creating the index on that data aborts migrate mid-deploy. Keep the newest row per identity and move its post_platforms over before dropping the duplicates - the FK is nullOnDelete, so deleting outright would orphan drafts and scheduled posts.
connectIdentity overwrote platform_user_id with whatever the provider returned, so reconnecting a card while signed into another account repointed the row - and every draft and scheduled post bound to it - at a stranger. LinkedIn guarded this at the controller and Facebook via its filtered page list; nothing covered X, TikTok, Threads, Discord, Bluesky, Mastodon, Pinterest, Instagram or Telegram. Enforce the identity match at the single choke point every connect flow goes through. Every call site already maps NetworkAlreadyConnectedException to network_taken, so the refusal surfaces without new plumbing. Also restore the null-platform guard in the observer: occupiesNetwork type-hints a non-nullable Platform, so a row without one died with a TypeError instead of the database's NOT NULL error.
YouTubeController::select re-fetched the channels and matched the posted id straight off the raw list, unlike callback and selectChannel. With a live youtube_oauth session it let a POST name any channel the Google account owns and bind it to the reconnect target. It also read the reconnect from the session while the connect below it read youtube_oauth.reconnect_id, so the two could disagree - pass the resolved account through instead. filterConnectableIdentities also short-circuited in multi-account mode, and the unique index is scoped to platform rather than network. That let one Instagram account connect twice, once directly and once via Facebook, publishing every Instagram post to it twice. The existing except() already spans networkPlatformValues(), so dropping the short-circuit closes it.
The cards computed inferred account as a required ConnectedAccount and then pushed undefined onto it (TS2345). CI only runs eslint so it stayed green, but vue-tsc and editors flag it. SocialAccountsGrid is referenced nowhere; its reconnect button was updated in this branch without passing the card id, which would have been a bug had anything rendered it.
CI runs the Cloud build, so the suite baseline should be the Cloud default rather than the self-hosted one. Put phpunit.xml and .env.ci back to false and make the eight tests that merely need two accounts of one network as a fixture opt in for themselves. This also un-deads the config()->set(true) calls the branch had already added to AuthenticationTest, SyncAccountUsageTest, HasUsageTraitTest and SocialAccountObserverTest, which the forced true had turned into no-ops.
The picker emits an already-resolved connect method, but this branch rewired @select from openOAuthPopup to startConnect. startConnect sends a bare 'instagram' straight back into its own picker branch, so choosing "Instagram" closed the dialog and immediately reopened it - the OAuth window never opened and the standalone flow was unreachable. Only the via-Facebook button still worked. Split the URL-opening tail out of startConnect and let the dialog call that directly.
The nonce was consumed before connectIdentity ran, so posting /connect in the wrong chat spent the one-off code and forced the user to generate a new one. Check the identity first and report wrong_chat instead of network_taken, which told them to disconnect an account when the real fix was posting in the channel they were reconnecting.
post_platforms has no unique on (post_id, social_account_id), so a post holding a row per duplicate account ended up with two enabled rows aimed at the surviving account and would publish to it twice. Keep one row per post, preferring a published one so history survives.
Four shapes were copy-pasted across the connect controllers: - the session + permission guard opening 16 actions, now connectWorkspace() throwing a ConnectPopupException that renders the popup itself - the reconnected/connected ternary in 13 places, now connectedCallback() - the "nothing left to connect" branch in 4 places, now noConnectableIdentities() - validatedReconnectId() re-querying what reconnectAccount() already does Facebook, Instagram-via-Facebook and YouTube also re-queried the reconnect account three or four times per callback; it is resolved once and passed down. The three GET pickers skipped the manageAccounts check their POST siblings had, and pick it up from the shared guard. Drops the color key from connectableOptions and the matching frontend field - nothing read it. Platform::color() stays; the disconnection emails use it.
ConnectPopupException escapes to the framework handler so it can render itself, which also meant report() ran first: every session_expired and workspace_not_found popup filed an ERROR and a Nightwatch issue for what used to be a silent return. A stale popup is a normal outcome, so it now implements ShouldntReport. The Mastodon and Threads guards also cleared their provider session after connectWorkspace(), so a workspace that vanished mid-flow left the client secret and the OAuth state behind. Clear first, then resolve. clearMastodonSession() no longer touches social_connect_workspace - whatever closes the popup already does.
Two flows reused popup_callback.network_taken - "This workspace already has an account for this network. Disconnect it first." - for situations where that is neither true nor actionable: - reconnecting a card while signed into a different account on the provider, now wrong_account - an empty picker in multi-account mode, where every page or channel on that login is simply already connected, now all_connected NetworkAlreadyConnectedException carries the message key so the catch sites stay one line. handleCallback() also drops its $platform argument; it read $this->platform for the reconnect lookup and the identity filter either way, so a caller passing a different platform would have scoped the lookup to the wrong network.
The picker hand-rolled its own reconnect narrowing because the profile and the pages arrive in two different shapes. Flatten them into one pool of LinkedIn identities, run the shared filter, and split them again for the view - the same path Facebook, YouTube and Instagram already take. Side effect worth having: the picker previously only narrowed on a reconnect, so it would offer an identity that is already connected and only fail once the user picked it. It now hides taken identities up front and says so when nothing is left.
Routing the picker through the shared filter made every empty pool look like "nothing left to take", including the pool LinkedIn never filled. A self-hoster running pages-only who administers no page was told the network was already connected, or that every account on the login was taken - both false - and the picker's own "you are not an admin of any LinkedIn page" state became unreachable. Only treat it as taken when filtering is what emptied it. Splitting the pool back also compared the person id loosely on one side and strictly on the other; one predicate now drives both. Threads had two forget() calls for a key the top of the action already clears, and YouTube's picker resolved the reconnect account twice on the failure path.
SyncPostPlatforms seeds a disabled post_platforms row for every account in the workspace, so the usual duplicate is one row the user actually checked next to one they never saw - both pending, both created in the same second. Ordering only by published-then-newest made that a coin flip, and PublishPost iterates enabled() only, so half the time a scheduled post would silently stop reaching that account and take its caption and per-platform meta with it. This runs once against production data and the dropped row is gone, so enabled now beats disabled. Also: the empty-pool exit from the LinkedIn picker was the only one leaving linkedin_pending - and its tokens - in the session. The rationale comments move to the docblocks they belong in, and usePage() comes out of the cards computed.
Two ways the one-shot merge lost data that cannot be rebuilt: Surplus published post_platforms rows were deleted. Two duplicate accounts really could each have published, and each row carries the platform_post_id for a live post on the network - dropping one leaves that post unmanageable and invisible to metrics. The docblock claimed published beat everything; now the code does, and only unpublished repeats collapse. Automation nodes persist social_account_id inside a JSON column with no foreign key, so deleting the loser left RunGenerateNode skipping that target, or generating nothing at all when it was the node's only account. The ids are rewritten - current and legacy shapes both - and entries the merge just turned into duplicates are collapsed. Ordering is now total (null created_at sorts oldest on every engine, then id) so a rehearsal on a replica keeps the same rows as the real run. The LinkedIn picker also passes onboardingProgress inline: it clears linkedin_pending on the empty path, and a deferred reload would re-GET the route and swap the empty state for a session-expired popup.
Self-hosted installs run this unattended and it cannot be undone, so each collapsed group now logs the workspace, the identity, which row was kept, which were dropped, and how many post_platforms and automations it touched. down() says plainly that it drops the index only. Two narrower fixes: A post holding a published row plus an enabled unpublished row for the same account kept both, and PostPlatform::scopeEnabled() filters on `enabled` alone with no status check - so a republish would deliver the same content to that identity twice. Once a published row exists, every unpublished repeat goes. The automation dedupe ran on every automation in the workspace, not just the ones the merge rewrote. A node legitimately holding two entries for one account under different content types would be collapsed to whichever came first in the array. It now runs only where an id was actually substituted.
Every test on this migration so far covered a case someone thought to write, which is why three separate review rounds each found a defect the earlier ones missed. This builds a deliberately messy database instead - three workspaces, four networks, one to three copies of each identity, posts mixing published, pending and failed rows across the duplicates with enabled flags varying, and automations referencing them in both the current and legacy JSON shapes - then runs the real migration and asserts what must be true afterwards rather than what happens to a particular fixture. Invariants: no duplicate identity survives, no published row is ever destroyed, no post ends up enabled twice against one account, nothing in post_platforms or automations points at a deleted account, and the newest row of each identity is the one kept. The generator is seeded, so a failure reproduces, and it asserts its own output is adversarial - roughly nine duplicate groups and fourteen published rows - so it cannot quietly degrade into passing on an empty problem. Verified by mutation: dropping the automation repoint, the published guard, or the repeated-target collapse each fails exactly the invariant that covers it.
HandleInertiaRequests defers onboardingProgress for anyone mid-onboarding - exactly the people connecting their first accounts - so Inertia re-GETs the picker route right after it mounts. For Facebook and Instagram that re-entry is harmless and deliberately left deferred, but YouTube calls the Google API again, and fetchChannels() turns any failure into an empty list that clears the connect session and swaps the mounted picker for an error the user cannot retry from. Same guard the LinkedIn picker already got. LinkedIn also answered a reconnect that authorized a different identity with "Page not found", including in the person branch where no page is involved. Every other platform says wrong_account, which this PR added.
Google's own delegation screen already lists every channel on the account and makes the user pick one before it issues the token, so channels?mine=true always answers with that single channel and count($channels) === 1 always won. The picker behind it was never reached - its Vue page was deleted back in 7c00c33 (January) and nothing broke, which is the clearest evidence it was dead. Removes selectChannel(), select(), both routes, the youtube_oauth session payload and the tests that drove them. If Google ever does return more than one, the callback connects the first and logs a warning rather than routing to a screen that no longer exists.
The observer's occupiesNetwork() is a check-then-insert with nothing holding the gap, and the new unique index covers the identity, not the network. Two tabs finishing OAuth at the same moment for *different* identities on one network both passed the exists() check and both inserted, leaving a Cloud workspace with the two accounts the rule exists to prevent. The same-identity race was already safe - the unique violation is caught and re-queried. A database constraint cannot hold this: allow_multiple_social_accounts is a runtime flag, so the rule is on for Cloud and off for self-hosted, and an index cannot read config. Lock per workspace and network instead, the way markAsDisconnected() and ConnectionVerifier already do. This covers connectIdentity(), which every OAuth flow and the Telegram action go through. A direct create() still answers to the observer alone, and a self-hosted install running file cache across several nodes locks per node.
Every other caller funnels LockTimeoutException into its generic \Exception catch and closes the popup with error_connecting. Telegram has no such catch, so the new lock could 500 the webhook - and because the nonce is spent before connectIdentity runs, Telegram's retry of the same update short-circuits on the consumed code and returns without dispatching anything. The dialog would spin forever on a code that can no longer be used. Also restores coverage the picker removal dropped: the deleted select tests were the only ones driving a multi-channel response, so nothing exercised the reconnect narrowing to its own card, or multi-account mode skipping an already-connected channel. Both are back against the callback, and removing the narrowing in filterConnectableIdentities fails them.
…ebook filterConnectableIdentities() drops every identity already connected on the network, which is what keeps one Instagram account from being seated twice under its two platforms. Every flow that persists an identity ran it except the direct Instagram Login callback, so the guard only held in one direction: InstagramFacebookController refused an account already connected as `instagram`, but the reverse was allowed through. With multiple accounts per network enabled the observer's network check is bypassed and the unique index does not span platforms, so authorizing the same account through the direct flow created a second row. Both then seed a post_platform row and the post goes out twice to one account.
…member Reconnecting a card narrows the authorized identities to that card's own, so authorizing a different LinkedIn login empties the pool. selectIdentity() reported that as "Page not found." for every card, including personal profiles where no page was ever involved. A profile reconnect has no page to be missing: an empty pool there can only mean this login is a different member. Say so with the wrong_account wording select() already uses for the same condition. Page reconnects keep page_not_found, where the organization really can be absent from the login.
The connect lock timing out dispatches its own 'busy' reason so the dialog can tell the user to retry, but the dialog only mapped network_taken and wrong_chat and fell back to error_generic for everything else. The reason reached the browser and died there, leaving "Could not start the connection" for a case that just needs another moment.
rememberConnectSession() gave Instagram, TikTok, Threads, Mastodon and Bluesky a reconnect path they did not have before — TikTok had been actively clearing social_reconnect_id on connect — and none of them had a test for it. Facebook, LinkedIn, YouTube, X, Discord, Pinterest and Telegram already did. Each now covers both halves: authorizing the same identity refreshes the existing card and reports it as a reconnect, and authorizing a different one is refused with wrong_account instead of quietly seating a stranger on the card and every post scheduled against it.
…anly Two things connectIdentity got wrong once the reconnect path existed. A reconnect through the other variant of a network moves the card to the new platform — same identity, different API flavor. Post targets carry their own platform snapshot, and that snapshot picks the publisher, the queue and the scopes checked before publishing. Left behind, it failed every pending post on a permission the account no longer needs: an Instagram card moved to the Facebook variant still demanded instagram_business_content_publish and stopped with "Missing permissions". Pending targets now follow the card and reset a content type the new platform cannot publish; published targets keep theirs, since they record what really went out under a platform_post_id from that API. The network lock timing out also arrived as a raw LockTimeoutException, which every OAuth callback filed through its generic catch: an error log and "Error connecting account" for the exact race the lock exists to absorb. It now carries a busy messageKey through the branch each flow already handles, the same way the Telegram path already reported it.
select() already looked the card up before deciding whether the chosen identity matches it, then connectPerson() and connectOrganization() looked it up again on their own — two identical queries per submit, and two places that could disagree about what is being reconnected. The caller passes what it already holds.
phpunit.xml forces ALLOW_MULTIPLE_SOCIAL_ACCOUNTS false and no browser test overrode it, so the card the flag exists to add never rendered anywhere. The pair pins both sides: a taken network offers no second card when multiples are off, and offers one when they are on.
connectIdentity() already refuses a mismatched reconnect and answers with the same wrong_account message, so every existing test passes with the two guards in select() deleted — which is exactly how they would get deleted. What they actually buy is skipping the avatar download that building the connect payload runs first. Both now assert the fetch never happens, so the guards fail loudly instead of looking redundant.
Two holes in the move added a commit ago. It only carried pending targets, but a retrying one is not finished either — the publish job reschedules itself and reads the snapshot fresh on the next attempt, so leaving it behind meant it retried against the old variant until it exhausted its budget on a permission the account no longer needs. Failed and published targets stay put; a publishing one has a job mid-flight already working from the snapshot it read. The card and its targets also moved in three separate statements, so a crash between them left exactly the split this was meant to close. They share a transaction now.
Laravel Dusk is not installed — no laravel/dusk requirement, no DuskTestCase, no browse(). Browser tests run on pest-plugin-browser driving Playwright, and its @selector resolves to data-testid. The 45 dusk attributes left across 18 components selected nothing. CLAUDE.md was the reason they kept coming back: it told every agent to add them. Its browser-testing section now describes the setup that exists — data-testid targeting, the wait helper these tests need because assertions do not auto-wait on SPA paint, and why BrowserTestCase keeps Vite real. Verified before removing: every @selector used in tests/Browser resolves to a data-testid, seven of them through bound :data-testid, so none depended on a dusk attribute.
hasConnectedPlatform() has no callers left anywhere — app, tests, views or routes. It sat directly above getSocialAccount(), which this branch already removed, and is the same leftover from when a workspace could hold one account per platform.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
ALLOW_MULTIPLE_SOCIAL_ACCOUNTS(trypost.allow_multiple_social_accounts) — independent ofSELF_HOSTED, so Cloud can flip it later without becoming self-hosted.SELF_HOSTEDso existing self-hosted installs keep multiple accounts. Compose /.env.examplestill set the flagtrue. Occupancy checks never readselfHosted.allowMultipleSocialAccountsand show a "Connect another" card when it is on. Already-connected identities always stay visible so they can be disconnected if the flag is later turned off.connectIdentity+ network-scoped?reconnect=). LinkedIn / Facebook / YouTube / Instagram-via-Facebook pickers only offer that card's identity; Telegram carries the card id in the signed/connectcode.(workspace_id, platform, platform_user_id). Reconnecting the same identity still updates the existing row; colliding with another card of that identity returnsnetwork_taken.Manual QA
Flip
ALLOW_MULTIPLE_SOCIAL_ACCOUNTSin.envand reload — config is not cached in dev, so noconfig:clearis needed (run it if you ever cache config). To force a card into its reconnect state without revoking access at the provider, set its status directly:One account per network (
ALLOW_MULTIPLE_SOCIAL_ACCOUNTS=false)Multiple per network (
ALLOW_MULTIPLE_SOCIAL_ACCOUNTS=true)falseand reload — both cards stay listed and can still be disconnectedReconnect keeps the card (and its scheduled posts)
Do this on at least LinkedIn plus one of Instagram / TikTok / Threads / Mastodon / Bluesky — those five had no reconnect path before this PR.
Instagram cannot be seated twice under one network
Needs the flag on and an Instagram account reachable both ways (direct login and through a Facebook page linked to it).
Telegram
Closes #197
Review follow-ups
Found while reviewing the branch; each fix ships with a test verified to fail without it.
Instagram could be seated twice under one network.
filterConnectableIdentities()drops identities already connected on the network, which is what stops one Instagram account from being held under both of its platforms. Every flow that persists an identity ran it except the direct Instagram Login callback, so the guard only held one way: connecting via Facebook refused an account already connected asinstagram, but not the reverse. With multiple accounts enabled the observer's check is bypassed and the unique index does not span platforms, so the same account got a second row — and twopost_platformrows, publishing the post twice to it.A profile reconnect that authorized another member said "Page not found." Reconnecting narrows the offered identities to the card's own, so a different LinkedIn login empties the pool.
selectIdentity()reported that as a missing page for every card, including personal profiles where no page was involved. Profile reconnects now saywrong_account, matching whatselect()already answered for the same condition; page reconnects keeppage_not_found.A variant move stranded the posts scheduled against the card. Reconnecting through the other variant of a network moves the card to the new platform — same identity, different API flavor. Post targets carry their own platform snapshot, and that snapshot picks the publisher, the queue and the scopes checked before publishing. Left behind, it failed every unpublished post on a permission the account no longer needs. Pending and retrying targets now move with the card inside one transaction, resetting a content type the new platform cannot publish; published and failed targets keep their snapshot.
A busy connect lock was filed as an incident. The lock exists because two connects on one network can race, but the timeout arrived as a raw
LockTimeoutExceptionthat every OAuth callback caught generically: an error log and "Error connecting account". It now carries abusymessage key through the branch each flow already handles, the same way the Telegram path reported it — and the Telegram dialog renders that reason instead of falling back to a generic error.Coverage added
social_reconnect_idon connect) and none had a test. Each covers both halves: the same identity refreshes the card, a different one is refused.phpunit.xmlforces the flag off and no browser test overrode it, so the card the flag exists to add never rendered anywhere.select()identity guards exist:connectIdentity()already refuses a mismatched reconnect with the same message, so the suite passed with them deleted. What they buy is skipping the avatar download that building the payload runs first — now asserted.