From 91c3d86d868d0f1413ed8cd7c9fdf53d063cddc7 Mon Sep 17 00:00:00 2001 From: Hafiz Muhammad Moaz <103947442+HafizMMoaz@users.noreply.github.com> Date: Tue, 25 Aug 2026 15:28:14 +0500 Subject: [PATCH] Allow multiple social accounts per network via env (#286) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: expose self-hosted mode to the accounts UI 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. * feat: allow connecting multiple accounts per network when self-hosted 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. * test: cover the selfHosted prop on accounts and onboarding pages 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. * style: apply prettier formatting Pre-existing drift in this file unrelated to the selfHosted change. * refactor: read selfHosted from shared Inertia props The flag is already shared by HandleInertiaRequests, so the accounts and onboarding controllers do not need to pass it again. Co-authored-by: Cursor * feat: gate multiple social accounts with a dedicated env Cloud cannot flip SELF_HOSTED, so one-per-network is now ALLOW_MULTIPLE_SOCIAL_ACCOUNTS (default false). Co-authored-by: Cursor * fix: tighten multiple-account gates after review Keep every connected identity visible, share occupiesNetwork, and return network_taken instead of a generic connect error. Co-authored-by: Cursor * fix: bind reconnect to the card and unique social identity 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 * refactor: build the OAuth URL before opening the popup Keep the popup opener URL-only so reconnect query params are assembled at the call site. Co-authored-by: Cursor * refactor: drop dead social-account guards and slim the connect grid 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 * refactor: scope social reconnect to the current network 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 * refactor: slim connectable-identity filtering Index OAuth identities by id so reconnect and occupancy use only/except instead of hand-rolled filters. Co-authored-by: Cursor * refactor: slim social identity persist helpers Drop the unused occupiesNetwork exception and persist reconnects with update() instead of fill/save/fresh. Co-authored-by: Cursor * fix: keep reconnect updates on the original social card Co-authored-by: Cursor * test: run the suite with multiple social accounts enabled 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. * fix: align the multi-account fallback with the self-hosted default 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. * fix: collapse duplicate identities before adding the unique index 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. * fix: refuse a reconnect that authorized a different identity 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. * fix: filter connectable identities on every picker step 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. * refactor: type the connect cards and drop the dead accounts grid 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. * test: keep the suite on the cloud one-account-per-network default 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. * fix: connect standalone instagram instead of reopening the picker 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. * fix: reject a telegram reconnect before burning the connect code 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. * fix: leave one target per post when merging duplicate accounts 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. * refactor: collapse the repeated connect-flow boilerplate 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. * fix: keep an expired connect popup out of the error log 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. * fix: stop telling users to disconnect an account that is not the problem 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. * refactor: filter linkedin identities with the shared helper 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. * fix: keep the linkedin picker's own empty state 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. * fix: keep the enabled row when collapsing duplicate post targets 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. * fix: stop the migration destroying publish history and automations 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. * fix: make the identity merge auditable and stop a second delivery 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. * test: rehearse the identity merge against a messy database 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. * fix: stop the youtube picker refetching itself into a cleared session 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. * refactor: drop the unreachable youtube channel picker 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 7c00c338 (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. * fix: serialize connects so two popups cannot seat one network twice 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. * fix: handle a busy connect lock on the telegram path 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. * fix: stop the instagram login seating an account already held via facebook 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. * fix: name the real reason when a linkedin profile reconnect switches 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. * fix: surface the busy telegram connect instead of a generic failure 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. * test: cover reconnect on every flow that gained it 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. * fix: repair what a reconnect leaves behind when it cannot proceed cleanly 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. * refactor: resolve the linkedin reconnect card once per select 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. * test: render the grid's multi-account branch 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. * test: pin why the linkedin select guards exist 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. * fix: carry retrying targets through a variant move, atomically 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. * chore: drop the dusk selectors nothing reads 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. * chore: drop the last one-account-per-network helper 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. --------- Co-authored-by: Paulo Castellano Co-authored-by: Cursor --- .env.ci | 1 + .env.example | 4 + AGENTS.md | 10 + CLAUDE.md | 28 +- .../SocialAccount/ConnectTelegramChannel.php | 83 ++-- app/Enums/SocialAccount/Platform.php | 3 +- .../SocialAccount/ConnectPopupException.php | 43 ++ .../NetworkAlreadyConnectedException.php | 26 +- .../Controllers/Auth/BlueskyController.php | 19 +- .../Controllers/Auth/DiscordController.php | 2 +- .../Controllers/Auth/FacebookController.php | 114 ++--- .../Controllers/Auth/InstagramController.php | 43 +- .../Auth/InstagramFacebookController.php | 116 ++--- .../Controllers/Auth/LinkedInController.php | 100 ++-- .../Controllers/Auth/MastodonController.php | 54 ++- .../Controllers/Auth/PinterestController.php | 53 +- .../Controllers/Auth/SocialController.php | 159 ++++-- .../Controllers/Auth/TelegramController.php | 2 +- .../Controllers/Auth/ThreadsController.php | 50 +- .../Controllers/Auth/TikTokController.php | 34 +- app/Http/Controllers/Auth/XController.php | 2 +- .../Controllers/Auth/YouTubeController.php | 219 ++------- .../Webhooks/TelegramWebhookController.php | 7 +- .../Middleware/App/HandleInertiaRequests.php | 1 + app/Models/SocialAccount.php | 137 ++++++ app/Models/Workspace.php | 10 - app/Observers/SocialAccountObserver.php | 17 +- .../Social/Telegram/TelegramConnectCode.php | 5 +- compose.prod.yaml | 1 + config/trypost.php | 20 + ...entity_unique_to_social_accounts_table.php | 239 +++++++++ docker/.env.docker.example | 3 + lang/ar/accounts.php | 6 + lang/de/accounts.php | 6 + lang/el/accounts.php | 6 + lang/en/accounts.php | 6 + lang/es/accounts.php | 6 + lang/fr/accounts.php | 6 + lang/it/accounts.php | 6 + lang/ja/accounts.php | 6 + lang/ko/accounts.php | 6 + lang/nl/accounts.php | 6 + lang/pl/accounts.php | 6 + lang/pt-BR/accounts.php | 6 + lang/ru/accounts.php | 6 + lang/tr/accounts.php | 6 + lang/uk/accounts.php | 6 + lang/zh/accounts.php | 6 + phpunit.xml | 1 + resources/js/components/AppSidebar.vue | 3 - .../js/components/SocialAccountsGrid.vue | 366 -------------- .../accounts/InstagramConnectDialog.vue | 9 +- .../accounts/NetworkConnectGrid.vue | 344 +++++-------- .../accounts/TelegramConnectDialog.vue | 23 +- .../automations/AutomationTabsNav.vue | 1 - .../components/settings/DeleteWorkspace.vue | 5 +- resources/js/composables/useOAuthPopup.ts | 28 +- resources/js/composables/usePlatformLogo.ts | 22 + resources/js/layouts/WelcomeLayout.vue | 2 - .../js/pages/accounts/FacebookPageSelect.vue | 3 +- .../accounts/InstagramFacebookPageSelect.vue | 3 +- .../js/pages/accounts/LinkedInSelect.vue | 8 +- resources/js/pages/accounts/PopupCallback.vue | 2 +- resources/js/pages/auth/Register.vue | 1 - .../js/pages/automations/Invocations.vue | 4 - resources/js/pages/automations/Metrics.vue | 2 +- resources/js/pages/automations/Settings.vue | 5 - resources/js/pages/mcp/Authorize.vue | 5 +- resources/js/pages/mcp/AuthorizeError.vue | 2 - resources/js/pages/onboarding/Index.vue | 7 +- resources/js/pages/welcome/Connect.vue | 3 - resources/js/pages/welcome/ReferralSource.vue | 1 - resources/js/types/index.d.ts | 1 + routes/app.php | 2 - tests/Browser/InstagramConnectDialogTest.php | 49 ++ tests/Browser/NetworkConnectGridTest.php | 73 +++ tests/Feature/Api/PostApiTest.php | 1 + tests/Feature/Auth/AuthenticationTest.php | 12 + .../Automation/Automation/ReadActionsTest.php | 1 + .../Commands/RefreshExpiringTokensTest.php | 1 + .../InstagramFacebookIntegrationTest.php | 1 + .../Jobs/PostHog/SyncAccountUsageTest.php | 1 + .../VerifyUpcomingPostConnectionsTest.php | 1 + tests/Feature/Mcp/PostToolTest.php | 1 + tests/Feature/Models/HasUsageTraitTest.php | 1 + .../Observers/SocialAccountObserverTest.php | 2 + .../Social/InstagramPublisherTest.php | 1 + .../Feature/Social/BlueskyControllerTest.php | 125 ++++- .../Social/ConnectPopupExceptionTest.php | 61 +++ .../Feature/Social/DiscordControllerTest.php | 99 ++++ .../Feature/Social/FacebookControllerTest.php | 288 ++++++++++- .../Social/InstagramControllerTest.php | 170 ++++++- .../InstagramFacebookControllerTest.php | 97 +++- .../Feature/Social/LinkedInControllerTest.php | 407 +++++++++++++++- .../Feature/Social/MastodonControllerTest.php | 139 +++++- .../Social/PinterestControllerTest.php | 79 ++- .../Feature/Social/TelegramConnectionTest.php | 121 ++++- .../Feature/Social/ThreadsControllerTest.php | 105 +++- tests/Feature/Social/TikTokControllerTest.php | 152 +++++- tests/Feature/Social/XControllerTest.php | 117 ++++- .../Feature/Social/YouTubeControllerTest.php | 312 ++++++++---- .../DuplicateIdentityMigrationTest.php | 410 ++++++++++++++++ .../DuplicateIdentityRehearsalTest.php | 210 ++++++++ .../MultipleAccountsConfigTest.php | 69 +++ .../SocialAccount/NetworkUniquenessTest.php | 458 +++++++++++++++++- tests/Feature/SocialAccountModelTest.php | 1 + tests/Feature/SocialControllerTest.php | 28 ++ 107 files changed, 4775 insertions(+), 1372 deletions(-) create mode 100644 app/Exceptions/SocialAccount/ConnectPopupException.php create mode 100644 database/migrations/2026_08_21_130941_add_workspace_platform_identity_unique_to_social_accounts_table.php delete mode 100644 resources/js/components/SocialAccountsGrid.vue create mode 100644 tests/Browser/InstagramConnectDialogTest.php create mode 100644 tests/Browser/NetworkConnectGridTest.php create mode 100644 tests/Feature/Social/ConnectPopupExceptionTest.php create mode 100644 tests/Feature/SocialAccount/DuplicateIdentityMigrationTest.php create mode 100644 tests/Feature/SocialAccount/DuplicateIdentityRehearsalTest.php create mode 100644 tests/Feature/SocialAccount/MultipleAccountsConfigTest.php diff --git a/.env.ci b/.env.ci index b72a8b5a..d7ce1a71 100644 --- a/.env.ci +++ b/.env.ci @@ -5,6 +5,7 @@ APP_DEBUG=true APP_URL=http://localhost SELF_HOSTED=true +ALLOW_MULTIPLE_SOCIAL_ACCOUNTS=false APP_LOCALE=en APP_FALLBACK_LOCALE=en diff --git a/.env.example b/.env.example index 2ca8be9c..38f5ab76 100644 --- a/.env.example +++ b/.env.example @@ -11,6 +11,10 @@ WEBHOOK_URL= # Self-hosted mode (skips payment requirements) SELF_HOSTED=true +# Allow more than one connected account per social network in a workspace. +# Independent of SELF_HOSTED (Cloud default is false). Self-hosted typically wants true. +ALLOW_MULTIPLE_SOCIAL_ACCOUNTS=true + # Passport OAuth keys (API tokens / MCP). Prefer env vars over key files so # every node behind a load balancer shares the same key pair. Use literal \n # for newlines in the PEM. When unset, Passport falls back to storage/oauth-*.key diff --git a/AGENTS.md b/AGENTS.md index 78dddbf0..9b1dbd0b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -222,6 +222,16 @@ ## Stripe Checkout (env knobs) - Coupon qualification stays: card required, exactly one workspace, no prior real subscription (`incomplete` / `incomplete_expired` still qualify). - Prefer documenting durable billing decisions here (and in `CLAUDE.md`) — do **not** create a `.ai/` rules folder for this project. +## Multiple social accounts per network + +One connected identity per social network per workspace is the Cloud default. This is **not** tied to `SELF_HOSTED` — Cloud cannot flip that flag, but it can flip this one. + +| Env | Config | Default | Effect | +| --- | --- | --- | --- | +| `ALLOW_MULTIPLE_SOCIAL_ACCOUNTS` | `trypost.allow_multiple_social_accounts` | `false` (falls back to `SELF_HOSTED` when unset) | `true`: a workspace may connect more than one account of the same network (two LinkedIns, two Instagrams, …). `false`: one per network (LinkedIn profile + page count as one; Instagram standalone + Instagram-via-Facebook count as one). Reconnecting the same `platform` + `platform_user_id` still updates the existing row. Shared to Inertia as `allowMultipleSocialAccounts`. | + +Self-hosted compose / `.env.example` set this `true`. When the env is unset, the config falls back to `SELF_HOSTED` so existing self-hosted installs keep multiple accounts. Do **not** use `selfHosted` for the occupancy check (observer, Telegram connect, `NetworkConnectGrid`). + ## Social Platform API Documentation (official sources) **Always consult the official docs below before implementing or changing OAuth, publishing, deletion, rate-limit, or any other platform-specific behavior — never guess endpoints, scopes, rate limits, or capabilities from memory.** APIs shift over time; a behavior confirmed in a past session may no longer hold. One entry per social network we integrate with: diff --git a/CLAUDE.md b/CLAUDE.md index 0279f606..4add397e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -244,6 +244,16 @@ ## Stripe Checkout (env knobs) - Coupon qualification stays: card required, exactly one workspace, no prior real subscription (`incomplete` / `incomplete_expired` still qualify). - Prefer documenting durable billing decisions here (and in `AGENTS.md`) — do **not** create a `.ai/` rules folder for this project. +## Multiple social accounts per network + +One connected identity per social network per workspace is the Cloud default. This is **not** tied to `SELF_HOSTED` — Cloud cannot flip that flag, but it can flip this one. + +| Env | Config | Default | Effect | +| --- | --- | --- | --- | +| `ALLOW_MULTIPLE_SOCIAL_ACCOUNTS` | `trypost.allow_multiple_social_accounts` | `false` (falls back to `SELF_HOSTED` when unset) | `true`: a workspace may connect more than one account of the same network (two LinkedIns, two Instagrams, …). `false`: one per network (LinkedIn profile + page count as one; Instagram standalone + Instagram-via-Facebook count as one). Reconnecting the same `platform` + `platform_user_id` still updates the existing row. Shared to Inertia as `allowMultipleSocialAccounts`. | + +Self-hosted compose / `.env.example` set this `true`. When the env is unset, the config falls back to `SELF_HOSTED` so existing self-hosted installs keep multiple accounts. Do **not** use `selfHosted` for the occupancy check (observer, Telegram connect, `NetworkConnectGrid`). + ## Icons (@tabler/icons-vue) - This project uses `@tabler/icons-vue` for all icons. NEVER use `lucide-vue-next`. @@ -304,13 +314,19 @@ ## Pest / Feature Tests - Example: `$this->postJson(route('app.posts.store'))` instead of `$this->postJson('/posts')`. - With params: `route('app.posts.ai.create.finalize', $creationId)`. -## Dusk (Browser Tests) +## Browser Tests (Pest + Playwright) -- In Dusk tests, ALWAYS use named routes via `route()` helper. NEVER hardcode URLs like `'https://trypost.test/login'`. - - Example: `$browser->visit(route('login'))` instead of `$browser->visit('https://trypost.test/login')`. -- ALWAYS use `dusk` selectors (`@selector-name`) for interacting with and asserting elements. NEVER use CSS classes (`.text-red-600`), tag names, or text strings. - - Add `dusk="my-element"` attributes to Vue components and use `$browser->click('@my-element')`, `$browser->waitFor('@my-element')`, etc. - - Example: `$browser->waitFor('@input-error')` instead of `$browser->waitFor('.text-red-600')`. +Browser tests live in `tests/Browser` and run on `pestphp/pest-plugin-browser` driving Playwright. **Laravel Dusk is not installed** — there is no `DuskTestCase`, no `$browser` object, and no `browse()`. Do not add `dusk="..."` attributes; they select nothing. + +- ALWAYS use named routes via `route()`. NEVER hardcode URLs like `'https://trypost.test/login'`. + - Example: `visit(route('login'))`. +- ALWAYS target elements by `data-testid`. NEVER use CSS classes (`.text-red-600`), tag names, or text strings. + - `@my-element` resolves to `[data-testid="my-element"]`, so add `data-testid="my-element"` in the Vue component and use `$page->click('@my-element')`. + - Bind it for repeated elements: `:data-testid="`connect-${platform.value}`"`. +- Assertions do NOT auto-wait on SPA paint. Wait for the element to mount and lay out first — see the `waitFor*TestId()` helper at the top of `tests/Browser/WelcomeConnectTest.php` and copy the pattern under a file-unique name (these helpers are global functions; a duplicated name collides across test files). +- `BrowserTestCase` sets `$fakesVite = false` on purpose: these tests load real built assets, so faking Vite blanks the app. +- End page assertions with `->assertNoJavaScriptErrors()`. +- CI runs them un-parallelised (`php artisan test tests/Browser --compact`) against `npm run build` output, so keep them independent of a running dev server. ## Array Data Access diff --git a/app/Actions/SocialAccount/ConnectTelegramChannel.php b/app/Actions/SocialAccount/ConnectTelegramChannel.php index 63d19f73..4dc922f7 100644 --- a/app/Actions/SocialAccount/ConnectTelegramChannel.php +++ b/app/Actions/SocialAccount/ConnectTelegramChannel.php @@ -8,6 +8,7 @@ use App\Enums\SocialAccount\Status; use App\Events\TelegramChannelConnected; use App\Events\TelegramConnectFailed; +use App\Exceptions\SocialAccount\NetworkAlreadyConnectedException; use App\Models\SocialAccount; use App\Models\Workspace; use App\Services\Social\Telegram\TelegramApi; @@ -24,51 +25,73 @@ class ConnectTelegramChannel * @return SocialAccount|null The linked account, or null when blocked (account * limit reached or the code was already consumed). */ - public static function execute(Workspace $workspace, array $chat, string $nonce): ?SocialAccount + public static function execute(Workspace $workspace, array $chat, string $nonce, mixed $reconnectId = null): ?SocialAccount { $chatId = (string) data_get($chat, 'id'); $username = data_get($chat, 'username'); + $reconnect = is_string($reconnectId) + ? $workspace->socialAccounts() + ->whereIn('platform', Platform::Telegram->networkPlatformValues()) + ->find($reconnectId) + : null; $isNewAccount = ! $workspace->socialAccounts() ->where('platform', Platform::Telegram->value) ->where('platform_user_id', $chatId) ->exists(); - if ($isNewAccount && self::networkAlreadyConnected($workspace, $chatId)) { + if ($reconnect === null && $isNewAccount && SocialAccount::occupiesNetwork((string) $workspace->id, Platform::Telegram)) { TelegramConnectFailed::dispatch($workspace->id, $nonce, 'network_taken'); return null; } + // Reject before consuming the nonce so the user can retry in the right + // chat with the code they already have. + if ($reconnect !== null && (string) $reconnect->platform_user_id !== $chatId) { + TelegramConnectFailed::dispatch($workspace->id, $nonce, 'wrong_chat'); + + return null; + } + // Consume the code once so a leaked code can't be replayed to link another chat. if (! Cache::add("telegram:connect:{$nonce}", true, now()->addMinutes(15))) { return null; } - $account = $workspace->socialAccounts()->updateOrCreate( - [ - 'platform' => Platform::Telegram->value, - 'platform_user_id' => $chatId, - ], - [ - 'username' => $username, - 'display_name' => data_get($chat, 'title') ?? $username ?? "Telegram {$chatId}", - 'avatar_url' => self::fetchChannelAvatar($chatId), - 'access_token' => '', - 'refresh_token' => '', - 'token_expires_at' => null, - 'scopes' => [], - 'status' => Status::Connected, - 'error_message' => null, - 'disconnected_at' => null, - 'meta' => [ - 'chat_id' => $chatId, + try { + $account = SocialAccount::connectIdentity( + $workspace, + Platform::Telegram, + $chatId, + [ 'username' => $username, - 'type' => data_get($chat, 'type'), - 'connect_nonce' => $nonce, + 'display_name' => data_get($chat, 'title') ?? $username ?? "Telegram {$chatId}", + 'avatar_url' => self::fetchChannelAvatar($chatId), + 'access_token' => '', + 'refresh_token' => '', + 'token_expires_at' => null, + 'scopes' => [], + 'status' => Status::Connected, + 'error_message' => null, + 'disconnected_at' => null, + 'meta' => [ + 'chat_id' => $chatId, + 'username' => $username, + 'type' => data_get($chat, 'type'), + 'connect_nonce' => $nonce, + ], ], - ], - ); + $reconnect, + ); + } catch (NetworkAlreadyConnectedException $e) { + // The nonce is already spent, so letting a busy lock reach the + // webhook would 500 to Telegram and its retry would short-circuit + // on the consumed code, leaving the dialog spinning with no error. + TelegramConnectFailed::dispatch($workspace->id, $nonce, $e->messageKey); + + return null; + } TelegramChannelConnected::dispatch($workspace->id, $nonce); @@ -102,16 +125,4 @@ private static function fetchChannelAvatar(string $chatId): ?string return null; } } - - private static function networkAlreadyConnected(Workspace $workspace, string $chatId): bool - { - if (config('trypost.self_hosted')) { - return false; - } - - return $workspace->socialAccounts() - ->whereIn('platform', Platform::Telegram->networkPlatformValues()) - ->where('platform_user_id', '!=', $chatId) - ->exists(); - } } diff --git a/app/Enums/SocialAccount/Platform.php b/app/Enums/SocialAccount/Platform.php index e665ed45..1d9d207c 100644 --- a/app/Enums/SocialAccount/Platform.php +++ b/app/Enums/SocialAccount/Platform.php @@ -431,7 +431,7 @@ public static function instagramConnectMethods(): array * Instagram includes `connect_methods` so the connect dialog only lists * OAuth entry points that are actually enabled (self-hosters may disable one). * - * @return list}> + * @return list}> */ public static function connectableOptions(): array { @@ -442,7 +442,6 @@ public static function connectableOptions(): array $option = [ 'value' => $platform->value, 'label' => $platform->label(), - 'color' => $platform->color(), 'network' => $platform->network(), ]; diff --git a/app/Exceptions/SocialAccount/ConnectPopupException.php b/app/Exceptions/SocialAccount/ConnectPopupException.php new file mode 100644 index 00000000..8cd2f13b --- /dev/null +++ b/app/Exceptions/SocialAccount/ConnectPopupException.php @@ -0,0 +1,43 @@ +forget(['social_connect_workspace', 'social_reconnect_id']); + + return Inertia::render('accounts/PopupCallback', [ + 'success' => false, + 'message' => __("accounts.popup_callback.{$this->messageKey}"), + 'platform' => $this->platform?->value, + 'onboardingProgress' => false, + ]); + } +} diff --git a/app/Exceptions/SocialAccount/NetworkAlreadyConnectedException.php b/app/Exceptions/SocialAccount/NetworkAlreadyConnectedException.php index af66de5a..4bb6909d 100644 --- a/app/Exceptions/SocialAccount/NetworkAlreadyConnectedException.php +++ b/app/Exceptions/SocialAccount/NetworkAlreadyConnectedException.php @@ -9,8 +9,30 @@ class NetworkAlreadyConnectedException extends RuntimeException { - public function __construct(public readonly Platform $platform) + public function __construct( + public readonly Platform $platform, + public readonly string $messageKey = 'network_taken', + ?string $reason = null, + ) { + parent::__construct($reason ?? "This workspace already has a {$platform->network()} account connected."); + } + + /** + * The provider handed back an account other than the one being reconnected, + * which is a different problem from the network slot being taken. + */ + public static function identityMismatch(Platform $platform): self { - parent::__construct("This workspace already has a {$platform->network()} account connected."); + return new self($platform, 'wrong_account', "The provider returned an identity other than the {$platform->network()} card being reconnected."); + } + + /** + * Another connect on this network holds the lock. Carried on this exception + * so it lands in the messageKey branch every connect flow already handles, + * rather than the generic catch that files a normal race as an error. + */ + public static function connectInProgress(Platform $platform): self + { + return new self($platform, 'busy', "Another {$platform->network()} connect is still finishing."); } } diff --git a/app/Http/Controllers/Auth/BlueskyController.php b/app/Http/Controllers/Auth/BlueskyController.php index cad4d5cc..0acc2faa 100644 --- a/app/Http/Controllers/Auth/BlueskyController.php +++ b/app/Http/Controllers/Auth/BlueskyController.php @@ -6,6 +6,8 @@ use App\Enums\SocialAccount\Platform as SocialPlatform; use App\Enums\SocialAccount\Status; +use App\Exceptions\SocialAccount\NetworkAlreadyConnectedException; +use App\Models\SocialAccount; use App\Services\Social\BlueskyLexicon; use Illuminate\Http\Request; use Illuminate\Support\Facades\Http; @@ -26,6 +28,8 @@ public function connect(Request $request): InertiaResponse $this->authorize('manageAccounts', $workspace); + $this->rememberConnectSession($request, $workspace); + return Inertia::render('accounts/BlueskyConnect', [ 'errors' => session('errors')?->getBag('default')?->toArray() ?? [], ]); @@ -79,12 +83,12 @@ public function store(Request $request): InertiaResponse $profile = $profileResponse->successful() ? $profileResponse->json() : []; $avatarPath = data_get($profile, 'avatar') ? uploadFromUrl(data_get($profile, 'avatar')) : null; + $reconnect = $this->reconnectAccount($workspace); - $workspace->socialAccounts()->updateOrCreate( - [ - 'platform' => $this->platform->value, - 'platform_user_id' => data_get($data, 'did'), - ], + SocialAccount::connectIdentity( + $workspace, + $this->platform, + (string) data_get($data, 'did'), [ 'username' => data_get($data, 'handle'), 'display_name' => data_get($profile, 'displayName', data_get($data, 'handle')), @@ -101,11 +105,14 @@ public function store(Request $request): InertiaResponse 'password' => encrypt($request->password), ], ], + $reconnect, ); - return $this->popupCallback(true, __('accounts.popup_callback.connected'), $this->platform->value); + return $this->connectedCallback($reconnect); } catch (ValidationException $e) { throw $e; + } catch (NetworkAlreadyConnectedException $e) { + return $this->popupCallback(false, __("accounts.popup_callback.{$e->messageKey}"), $this->platform->value); } catch (\Exception $e) { Log::error('Bluesky connection error', [ 'error' => $e->getMessage(), diff --git a/app/Http/Controllers/Auth/DiscordController.php b/app/Http/Controllers/Auth/DiscordController.php index 6166e9fe..d4218980 100644 --- a/app/Http/Controllers/Auth/DiscordController.php +++ b/app/Http/Controllers/Auth/DiscordController.php @@ -28,6 +28,6 @@ public function connect(Request $request): Response public function callback(Request $request): InertiaResponse { - return $this->handleCallback($request, $this->platform, $this->driver); + return $this->handleCallback($request, $this->driver); } } diff --git a/app/Http/Controllers/Auth/FacebookController.php b/app/Http/Controllers/Auth/FacebookController.php index 0c9d9236..d7748339 100644 --- a/app/Http/Controllers/Auth/FacebookController.php +++ b/app/Http/Controllers/Auth/FacebookController.php @@ -6,8 +6,9 @@ use App\Enums\SocialAccount\Platform as SocialPlatform; use App\Enums\SocialAccount\Status; +use App\Exceptions\SocialAccount\ConnectPopupException; use App\Exceptions\SocialAccount\NetworkAlreadyConnectedException; -use App\Models\Workspace; +use App\Models\SocialAccount; use App\Services\Social\Meta\GraphPaginator; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; @@ -42,10 +43,7 @@ public function connect(Request $request): Response $this->authorize('manageAccounts', $workspace); - session([ - 'social_connect_workspace' => $workspace->id, - 'social_reconnect_id' => null, - ]); + $this->rememberConnectSession($request, $workspace); return Inertia::location( Socialite::driver($this->driver) @@ -58,17 +56,9 @@ public function connect(Request $request): Response public function callback(Request $request): InertiaResponse|RedirectResponse { - $workspaceId = session('social_connect_workspace'); + $workspace = $this->connectWorkspace($request); - if (! $workspaceId) { - return $this->popupCallback(false, __('accounts.popup_callback.session_expired'), $this->platform->value); - } - - $workspace = Workspace::find($workspaceId); - - if (! $workspace || ! $request->user()->can('manageAccounts', $workspace)) { - return $this->popupCallback(false, __('accounts.popup_callback.workspace_not_found'), $this->platform->value); - } + $reconnect = $this->reconnectAccount($workspace); try { $socialUser = Socialite::driver($this->driver)->usingGraphVersion($this->graphVersion())->user(); @@ -80,23 +70,27 @@ public function callback(Request $request): InertiaResponse|RedirectResponse 'access_token' => $socialUser->token, ]); - // Fetch pages the user manages $pages = $this->fetchPages($socialUser->token); if (empty($pages)) { return $this->popupCallback(false, __('accounts.popup_callback.no_facebook_pages'), $this->platform->value); } + $pages = $this->filterConnectableIdentities($workspace, $pages, 'id', $reconnect); + + if (empty($pages)) { + return $this->noConnectableIdentities($reconnect, 'page_not_found'); + } + // If only one page, connect directly if (count($pages) === 1) { $page = $pages[0]; $avatarPath = uploadFromUrl(data_get($page, 'picture')); - $workspace->socialAccounts()->updateOrCreate( - [ - 'platform' => $this->platform->value, - 'platform_user_id' => data_get($page, 'id'), - ], + SocialAccount::connectIdentity( + $workspace, + $this->platform, + (string) data_get($page, 'id'), [ 'username' => data_get($page, 'username', null), 'display_name' => data_get($page, 'name'), @@ -114,9 +108,10 @@ public function callback(Request $request): InertiaResponse|RedirectResponse 'user_token' => $socialUser->token, ], ], + $reconnect, ); - return $this->popupCallback(true, __('accounts.popup_callback.connected'), $this->platform->value); + return $this->connectedCallback($reconnect); } // Multiple pages - store data and show selection @@ -125,12 +120,13 @@ public function callback(Request $request): InertiaResponse|RedirectResponse 'user_token' => $socialUser->token, 'user_id' => $socialUser->getId(), 'pages' => $pages, + 'reconnect_id' => $reconnect?->id, ], ]); return redirect()->route('app.social.facebook.select-page'); - } catch (NetworkAlreadyConnectedException) { - return $this->popupCallback(false, __('accounts.popup_callback.network_taken'), $this->platform->value); + } catch (NetworkAlreadyConnectedException $e) { + return $this->popupCallback(false, __("accounts.popup_callback.{$e->messageKey}"), $this->platform->value); } catch (\Exception $e) { Log::error('Facebook OAuth Error', [ 'error' => $e->getMessage(), @@ -144,17 +140,12 @@ public function callback(Request $request): InertiaResponse|RedirectResponse public function selectPage(Request $request): InertiaResponse { $oauthData = session('facebook_oauth'); - $workspaceId = session('social_connect_workspace'); - if (! $oauthData || ! $workspaceId) { - return $this->popupCallback(false, __('accounts.popup_callback.session_expired'), $this->platform->value); + if (! $oauthData) { + throw new ConnectPopupException('session_expired', $this->platform); } - $workspace = Workspace::find($workspaceId); - - if (! $workspace) { - return $this->popupCallback(false, __('accounts.popup_callback.workspace_not_found'), $this->platform->value); - } + $workspace = $this->connectWorkspace($request); $pages = collect(data_get($oauthData, 'pages')) ->map(fn ($page) => Arr::except($page, ['access_token'])) @@ -173,17 +164,12 @@ public function select(Request $request): InertiaResponse ]); $oauthData = session('facebook_oauth'); - $workspaceId = session('social_connect_workspace'); - if (! $oauthData || ! $workspaceId) { - return $this->popupCallback(false, __('accounts.popup_callback.session_expired'), $this->platform->value); + if (! $oauthData) { + throw new ConnectPopupException('session_expired', $this->platform); } - $workspace = Workspace::find($workspaceId); - - if (! $workspace || ! $request->user()->can('manageAccounts', $workspace)) { - return $this->popupCallback(false, __('accounts.popup_callback.workspace_not_found'), $this->platform->value); - } + $workspace = $this->connectWorkspace($request); try { $selectedPage = collect(data_get($oauthData, 'pages'))->firstWhere('id', $request->page_id); @@ -193,41 +179,12 @@ public function select(Request $request): InertiaResponse } $avatarPath = uploadFromUrl(data_get($selectedPage, 'picture')); - $reconnectId = data_get($oauthData, 'reconnect_id'); + $reconnect = $this->reconnectAccount($workspace, data_get($oauthData, 'reconnect_id')); - if ($reconnectId) { - // Reconnect existing account - $existingAccount = $workspace->socialAccounts()->find($reconnectId); - - if ($existingAccount) { - $existingAccount->update([ - 'platform_user_id' => data_get($selectedPage, 'id'), - 'username' => data_get($selectedPage, 'username') ?? null, - 'display_name' => data_get($selectedPage, 'name'), - 'avatar_url' => $avatarPath, - 'access_token' => data_get($selectedPage, 'access_token'), - 'refresh_token' => null, - 'token_expires_at' => null, - 'scopes' => $this->scopes, - 'meta' => [ - 'page_id' => data_get($selectedPage, 'id'), - 'user_id' => data_get($oauthData, 'user_id'), - 'user_token' => data_get($oauthData, 'user_token'), - ], - ]); - $existingAccount->markAsConnected(); - - session()->forget(['facebook_oauth', 'social_reconnect_id']); - - return $this->popupCallback(true, __('accounts.popup_callback.reconnected'), $this->platform->value); - } - } - - $workspace->socialAccounts()->updateOrCreate( - [ - 'platform' => $this->platform->value, - 'platform_user_id' => data_get($selectedPage, 'id'), - ], + SocialAccount::connectIdentity( + $workspace, + $this->platform, + (string) data_get($selectedPage, 'id'), [ 'username' => data_get($selectedPage, 'username') ?? null, 'display_name' => data_get($selectedPage, 'name'), @@ -245,13 +202,14 @@ public function select(Request $request): InertiaResponse 'user_token' => data_get($oauthData, 'user_token'), ], ], + $reconnect, ); - session()->forget(['facebook_oauth', 'social_reconnect_id']); + session()->forget('facebook_oauth'); - return $this->popupCallback(true, __('accounts.popup_callback.connected'), $this->platform->value); - } catch (NetworkAlreadyConnectedException) { - return $this->popupCallback(false, __('accounts.popup_callback.network_taken'), $this->platform->value); + return $this->connectedCallback($reconnect); + } catch (NetworkAlreadyConnectedException $e) { + return $this->popupCallback(false, __("accounts.popup_callback.{$e->messageKey}"), $this->platform->value); } catch (\Exception $e) { Log::error('Facebook page selection error', [ 'error' => $e->getMessage(), diff --git a/app/Http/Controllers/Auth/InstagramController.php b/app/Http/Controllers/Auth/InstagramController.php index f8a0c054..5a6fdbea 100644 --- a/app/Http/Controllers/Auth/InstagramController.php +++ b/app/Http/Controllers/Auth/InstagramController.php @@ -7,7 +7,7 @@ use App\Enums\SocialAccount\Platform as SocialPlatform; use App\Enums\SocialAccount\Status; use App\Exceptions\SocialAccount\NetworkAlreadyConnectedException; -use App\Models\Workspace; +use App\Models\SocialAccount; use Illuminate\Http\Request; use Illuminate\Support\Facades\Log; use Inertia\Inertia; @@ -35,10 +35,7 @@ public function connect(Request $request): Response $this->authorize('manageAccounts', $workspace); - session([ - 'social_connect_workspace' => $workspace->id, - 'social_reconnect_id' => null, - ]); + $this->rememberConnectSession($request, $workspace); $url = Socialite::driver($this->driver) ->scopes($this->scopes) @@ -50,17 +47,7 @@ public function connect(Request $request): Response public function callback(Request $request): InertiaResponse { - $workspaceId = session('social_connect_workspace'); - - if (! $workspaceId) { - return $this->popupCallback(false, __('accounts.popup_callback.session_expired'), $this->platform->value); - } - - $workspace = Workspace::find($workspaceId); - - if (! $workspace || ! $request->user()->can('manageAccounts', $workspace)) { - return $this->popupCallback(false, __('accounts.popup_callback.workspace_not_found'), $this->platform->value); - } + $workspace = $this->connectWorkspace($request); try { $socialUser = Socialite::driver($this->driver)->user(); @@ -71,12 +58,19 @@ public function callback(Request $request): InertiaResponse // Calculate token expiration (long-lived tokens last 60 days) $expiresIn = $socialUser->expiresIn ?? $this->platform->defaultTokenTtlSeconds(); $tokenExpiresAt = now()->addSeconds($expiresIn); + $reconnect = $this->reconnectAccount($workspace); - $workspace->socialAccounts()->updateOrCreate( - [ - 'platform' => $this->platform->value, - 'platform_user_id' => $socialUser->getId(), - ], + // Instagram Login returns a single identity, but it shares a network + // with the Facebook variant: without this the same account could be + // seated twice, once under each platform. + if ($this->filterConnectableIdentities($workspace, [['id' => $socialUser->getId()]], 'id', $reconnect) === []) { + return $this->noConnectableIdentities($reconnect, 'wrong_account'); + } + + SocialAccount::connectIdentity( + $workspace, + $this->platform, + $socialUser->getId(), [ 'username' => $socialUser->getNickname(), 'display_name' => $socialUser->getName() ?? $socialUser->getNickname(), @@ -92,11 +86,12 @@ public function callback(Request $request): InertiaResponse 'account_type' => $socialUser->user['account_type'] ?? null, ], ], + $reconnect, ); - return $this->popupCallback(true, __('accounts.popup_callback.connected'), $this->platform->value); - } catch (NetworkAlreadyConnectedException) { - return $this->popupCallback(false, __('accounts.popup_callback.network_taken'), $this->platform->value); + return $this->connectedCallback($reconnect); + } catch (NetworkAlreadyConnectedException $e) { + return $this->popupCallback(false, __("accounts.popup_callback.{$e->messageKey}"), $this->platform->value); } catch (\Exception $e) { Log::error('Instagram OAuth Error', [ 'error' => $e->getMessage(), diff --git a/app/Http/Controllers/Auth/InstagramFacebookController.php b/app/Http/Controllers/Auth/InstagramFacebookController.php index 2ed48574..536c7e28 100644 --- a/app/Http/Controllers/Auth/InstagramFacebookController.php +++ b/app/Http/Controllers/Auth/InstagramFacebookController.php @@ -6,7 +6,9 @@ use App\Enums\SocialAccount\Platform as SocialPlatform; use App\Enums\SocialAccount\Status; +use App\Exceptions\SocialAccount\ConnectPopupException; use App\Exceptions\SocialAccount\NetworkAlreadyConnectedException; +use App\Models\SocialAccount; use App\Models\Workspace; use App\Services\Social\Meta\GraphPaginator; use Illuminate\Http\Client\ConnectionException; @@ -45,10 +47,7 @@ public function connect(Request $request): Response $this->authorize('manageAccounts', $workspace); - session([ - 'social_connect_workspace' => $workspace->id, - 'social_reconnect_id' => null, - ]); + $this->rememberConnectSession($request, $workspace); $url = Socialite::driver($this->driver) ->usingGraphVersion($this->graphVersion()) @@ -62,20 +61,9 @@ public function connect(Request $request): Response public function callback(Request $request): InertiaResponse|RedirectResponse { - $workspaceId = session('social_connect_workspace'); + $workspace = $this->connectWorkspace($request); - if (! $workspaceId) { - return $this->popupCallback(false, __('accounts.popup_callback.session_expired'), $this->platform->value); - } - - $workspace = Workspace::find($workspaceId); - - if (! $workspace || ! $request->user()->can('manageAccounts', $workspace)) { - return $this->popupCallback(false, __('accounts.popup_callback.workspace_not_found'), $this->platform->value); - } - - $reconnectId = session('social_reconnect_id'); - $existingAccount = $reconnectId ? $workspace->socialAccounts()->find($reconnectId) : null; + $existingAccount = $this->reconnectAccount($workspace); try { $socialUser = Socialite::driver($this->driver) @@ -95,6 +83,12 @@ public function callback(Request $request): InertiaResponse|RedirectResponse return $this->popupCallback(false, __('accounts.popup_callback.no_facebook_instagram_pages'), $this->platform->value); } + $pages = $this->filterConnectableIdentities($workspace, $pages, 'ig_id', $existingAccount); + + if (empty($pages)) { + return $this->noConnectableIdentities($existingAccount, 'page_not_found'); + } + if (count($pages) === 1) { return $this->connectInstagramAccount($workspace, $pages[0], $existingAccount); } @@ -104,13 +98,13 @@ public function callback(Request $request): InertiaResponse|RedirectResponse 'instagram_facebook_oauth' => [ 'user_token' => $socialUser->token, 'pages' => $pages, - 'reconnect_id' => $reconnectId, + 'reconnect_id' => $existingAccount?->id, ], ]); return redirect()->route('app.social.instagram-facebook.select-page'); - } catch (NetworkAlreadyConnectedException) { - return $this->popupCallback(false, __('accounts.popup_callback.network_taken'), $this->platform->value); + } catch (NetworkAlreadyConnectedException $e) { + return $this->popupCallback(false, __("accounts.popup_callback.{$e->messageKey}"), $this->platform->value); } catch (\Exception $e) { Log::error('Instagram via Facebook OAuth Error', [ 'error' => $e->getMessage(), @@ -124,17 +118,12 @@ public function callback(Request $request): InertiaResponse|RedirectResponse public function selectPage(Request $request): InertiaResponse { $oauthData = session('instagram_facebook_oauth'); - $workspaceId = session('social_connect_workspace'); - if (! $oauthData || ! $workspaceId) { - return $this->popupCallback(false, __('accounts.popup_callback.session_expired'), $this->platform->value); + if (! $oauthData) { + throw new ConnectPopupException('session_expired', $this->platform); } - $workspace = Workspace::find($workspaceId); - - if (! $workspace) { - return $this->popupCallback(false, __('accounts.popup_callback.workspace_not_found'), $this->platform->value); - } + $workspace = $this->connectWorkspace($request); $pages = collect(data_get($oauthData, 'pages')) ->map(fn ($page) => Arr::except($page, ['page_access_token'])) @@ -153,20 +142,14 @@ public function select(Request $request): InertiaResponse ]); $oauthData = session('instagram_facebook_oauth'); - $workspaceId = session('social_connect_workspace'); - if (! $oauthData || ! $workspaceId) { - return $this->popupCallback(false, __('accounts.popup_callback.session_expired'), $this->platform->value); + if (! $oauthData) { + throw new ConnectPopupException('session_expired', $this->platform); } - $workspace = Workspace::find($workspaceId); + $workspace = $this->connectWorkspace($request); - if (! $workspace || ! $request->user()->can('manageAccounts', $workspace)) { - return $this->popupCallback(false, __('accounts.popup_callback.workspace_not_found'), $this->platform->value); - } - - $reconnectId = data_get($oauthData, 'reconnect_id'); - $existingAccount = $reconnectId ? $workspace->socialAccounts()->find($reconnectId) : null; + $existingAccount = $this->reconnectAccount($workspace, data_get($oauthData, 'reconnect_id')); try { $selectedPage = collect(data_get($oauthData, 'pages'))->firstWhere('page_id', $request->page_id); @@ -177,11 +160,11 @@ public function select(Request $request): InertiaResponse $result = $this->connectInstagramAccount($workspace, $selectedPage, $existingAccount); - session()->forget(['instagram_facebook_oauth', 'social_reconnect_id']); + session()->forget('instagram_facebook_oauth'); return $result; - } catch (NetworkAlreadyConnectedException) { - return $this->popupCallback(false, __('accounts.popup_callback.network_taken'), $this->platform->value); + } catch (NetworkAlreadyConnectedException $e) { + return $this->popupCallback(false, __("accounts.popup_callback.{$e->messageKey}"), $this->platform->value); } catch (\Exception $e) { Log::error('Instagram via Facebook page selection error', ['error' => $e->getMessage()]); @@ -189,47 +172,34 @@ public function select(Request $request): InertiaResponse } } - private function connectInstagramAccount(Workspace $workspace, array $pageData, $existingAccount): InertiaResponse + private function connectInstagramAccount(Workspace $workspace, array $pageData, ?SocialAccount $existingAccount): InertiaResponse { $avatarPath = data_get($pageData, 'ig_picture') ? uploadFromUrl(data_get($pageData, 'ig_picture')) : null; - $accountData = [ - 'platform_user_id' => data_get($pageData, 'ig_id'), - 'username' => data_get($pageData, 'ig_username'), - 'display_name' => data_get($pageData, 'ig_name', data_get($pageData, 'ig_username')), - 'avatar_url' => $avatarPath, - 'access_token' => data_get($pageData, 'page_access_token'), - 'refresh_token' => null, - 'token_expires_at' => null, - 'scopes' => $this->scopes, - 'meta' => [ - 'page_id' => data_get($pageData, 'page_id'), - 'page_name' => data_get($pageData, 'page_name'), - ], - ]; - - if ($existingAccount) { - $existingAccount->update($accountData); - $existingAccount->markAsConnected(); - - session()->forget('social_reconnect_id'); - - return $this->popupCallback(true, __('accounts.popup_callback.reconnected'), $this->platform->value); - } - - $account = $workspace->socialAccounts()->updateOrCreate( + SocialAccount::connectIdentity( + $workspace, + $this->platform, + (string) data_get($pageData, 'ig_id'), [ - 'platform' => $this->platform->value, - 'platform_user_id' => data_get($pageData, 'ig_id'), - ], - array_merge($accountData, [ + 'username' => data_get($pageData, 'ig_username'), + 'display_name' => data_get($pageData, 'ig_name', data_get($pageData, 'ig_username')), + 'avatar_url' => $avatarPath, + 'access_token' => data_get($pageData, 'page_access_token'), + 'refresh_token' => null, + 'token_expires_at' => null, + 'scopes' => $this->scopes, 'status' => Status::Connected, 'error_message' => null, 'disconnected_at' => null, - ]), + 'meta' => [ + 'page_id' => data_get($pageData, 'page_id'), + 'page_name' => data_get($pageData, 'page_name'), + ], + ], + $existingAccount, ); - return $this->popupCallback(true, __('accounts.popup_callback.connected'), $this->platform->value); + return $this->connectedCallback($existingAccount); } private function fetchPagesWithInstagram(string $userToken): array diff --git a/app/Http/Controllers/Auth/LinkedInController.php b/app/Http/Controllers/Auth/LinkedInController.php index 743885f4..323c4cd7 100644 --- a/app/Http/Controllers/Auth/LinkedInController.php +++ b/app/Http/Controllers/Auth/LinkedInController.php @@ -8,6 +8,7 @@ use App\Enums\SocialAccount\Platform as SocialPlatform; use App\Enums\SocialAccount\Status; use App\Exceptions\SocialAccount\NetworkAlreadyConnectedException; +use App\Models\SocialAccount; use App\Models\Workspace; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; @@ -46,7 +47,7 @@ public function connect(Request $request): Response $this->authorize('manageAccounts', $workspace); - session(['social_connect_workspace' => $workspace->id]); + $this->rememberConnectSession($request, $workspace); return Inertia::location( Socialite::driver($this->driver) @@ -58,17 +59,7 @@ public function connect(Request $request): Response public function callback(Request $request): InertiaResponse|RedirectResponse { - $workspaceId = session('social_connect_workspace'); - - if (! $workspaceId) { - return $this->popupCallback(false, __('accounts.popup_callback.session_expired'), $this->platform->value); - } - - $workspace = Workspace::find($workspaceId); - - if (! $workspace || ! $request->user()->can('manageAccounts', $workspace)) { - return $this->popupCallback(false, __('accounts.popup_callback.workspace_not_found'), $this->platform->value); - } + $workspace = $this->connectWorkspace($request); try { $socialUser = Socialite::driver($this->driver)->user(); @@ -100,6 +91,15 @@ public function callback(Request $request): InertiaResponse|RedirectResponse } } + /** + * Render the identity picker. + * + * The pending payload carries its own workspace, so the picker survives a + * cleared connect session where connectWorkspace() would not. The profile + * and the pages are one pool of LinkedIn identities: they go through the + * shared filter together and are split again for the view, and only the + * filter emptying the pool counts as the network being taken. + */ public function selectIdentity(Request $request): InertiaResponse { $pending = session('linkedin_pending'); @@ -114,9 +114,37 @@ public function selectIdentity(Request $request): InertiaResponse return $this->popupCallback(false, __('accounts.popup_callback.workspace_not_found'), $this->platform->value); } + $identities = array_values(array_filter([ + $this->personEnabled() ? $pending['person'] : null, + ...$pending['organizations'], + ])); + + $reconnect = $this->reconnectAccount($workspace); + $connectable = collect($this->filterConnectableIdentities($workspace, $identities, 'id', $reconnect)); + + if ($identities !== [] && $connectable->isEmpty()) { + session()->forget('linkedin_pending'); + + // A profile reconnect has no page to be missing: the pool emptying + // means this login is a different member than the card being + // reconnected. + return $this->noConnectableIdentities( + $reconnect, + $reconnect?->platform === SocialPlatform::LinkedIn ? 'wrong_account' : 'page_not_found', + ); + } + + if ($connectable->isEmpty()) { + session()->forget('linkedin_pending'); + } + + $personId = (string) data_get($pending, 'person.id'); + $isPerson = fn (array $identity): bool => (string) data_get($identity, 'id') === $personId; + return Inertia::render('accounts/LinkedInSelect', [ - 'person' => $this->personEnabled() ? $pending['person'] : null, - 'organizations' => $pending['organizations'], + 'person' => $connectable->first($isPerson), + 'organizations' => $connectable->reject($isPerson)->values()->all(), + 'onboardingProgress' => false, ]); } @@ -152,6 +180,8 @@ public function select(Request $request): InertiaResponse return $this->popupCallback(false, __('accounts.popup_callback.error_connecting'), $this->platform->value); } + $reconnect = $this->reconnectAccount($workspace); + try { if ($type === LinkedInIdentityType::Organization) { $organization = $this->resolveAdministeredOrganization($pending, data_get($validated, 'organization_id')); @@ -160,16 +190,24 @@ public function select(Request $request): InertiaResponse return $this->popupCallback(false, __('accounts.popup_callback.error_connecting'), $this->platform->value); } - $this->connectOrganization($workspace, $pending, $organization); + if ($reconnect !== null && (string) data_get($organization, 'id') !== (string) $reconnect->platform_user_id) { + return $this->popupCallback(false, __('accounts.popup_callback.wrong_account'), $this->platform->value); + } + + $this->connectOrganization($workspace, $pending, $organization, $reconnect); } else { - $this->connectPerson($workspace, $pending); + if ($reconnect !== null && (string) data_get($pending, 'person.id') !== (string) $reconnect->platform_user_id) { + return $this->popupCallback(false, __('accounts.popup_callback.wrong_account'), $this->platform->value); + } + + $this->connectPerson($workspace, $pending, $reconnect); } session()->forget('linkedin_pending'); - return $this->popupCallback(true, __('accounts.popup_callback.connected'), $this->platform->value); - } catch (NetworkAlreadyConnectedException) { - return $this->popupCallback(false, __('accounts.popup_callback.network_taken'), $this->platform->value); + return $this->connectedCallback($reconnect); + } catch (NetworkAlreadyConnectedException $e) { + return $this->popupCallback(false, __("accounts.popup_callback.{$e->messageKey}"), $this->platform->value); } catch (\Exception $e) { Log::error('LinkedIn selection error', [ 'error' => $e->getMessage(), @@ -182,15 +220,14 @@ public function select(Request $request): InertiaResponse /** * The user's personal LinkedIn profile becomes a `linkedin` account. */ - private function connectPerson(Workspace $workspace, array $pending): void + private function connectPerson(Workspace $workspace, array $pending, ?SocialAccount $reconnect): void { $person = $pending['person']; - $workspace->socialAccounts()->updateOrCreate( - [ - 'platform' => SocialPlatform::LinkedIn->value, - 'platform_user_id' => data_get($person, 'id'), - ], + SocialAccount::connectIdentity( + $workspace, + SocialPlatform::LinkedIn, + (string) data_get($person, 'id'), [ 'username' => data_get($person, 'vanity_name'), 'display_name' => data_get($person, 'name'), @@ -203,6 +240,7 @@ private function connectPerson(Workspace $workspace, array $pending): void 'error_message' => null, 'disconnected_at' => null, ], + $reconnect, ); } @@ -229,15 +267,14 @@ private function resolveAdministeredOrganization(array $pending, mixed $organiza * @param array $pending * @param array $organization */ - private function connectOrganization(Workspace $workspace, array $pending, array $organization): void + private function connectOrganization(Workspace $workspace, array $pending, array $organization, ?SocialAccount $reconnect): void { $organizationId = data_get($organization, 'id'); - $workspace->socialAccounts()->updateOrCreate( - [ - 'platform' => SocialPlatform::LinkedInPage->value, - 'platform_user_id' => $organizationId, - ], + SocialAccount::connectIdentity( + $workspace, + SocialPlatform::LinkedInPage, + (string) $organizationId, [ 'username' => data_get($organization, 'vanity_name'), 'display_name' => data_get($organization, 'name'), @@ -255,6 +292,7 @@ private function connectOrganization(Workspace $workspace, array $pending, array 'admin_name' => data_get($pending, 'person.name'), ], ], + $reconnect, ); } diff --git a/app/Http/Controllers/Auth/MastodonController.php b/app/Http/Controllers/Auth/MastodonController.php index 2dd0966e..f3155020 100644 --- a/app/Http/Controllers/Auth/MastodonController.php +++ b/app/Http/Controllers/Auth/MastodonController.php @@ -6,7 +6,9 @@ use App\Enums\SocialAccount\Platform as SocialPlatform; use App\Enums\SocialAccount\Status; -use App\Models\Workspace; +use App\Exceptions\SocialAccount\ConnectPopupException; +use App\Exceptions\SocialAccount\NetworkAlreadyConnectedException; +use App\Models\SocialAccount; use Illuminate\Http\Request; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Log; @@ -31,6 +33,8 @@ public function connect(Request $request): InertiaResponse $this->authorize('manageAccounts', $workspace); + $this->rememberConnectSession($request, $workspace); + return Inertia::render('accounts/MastodonConnect', [ 'errors' => session('errors')?->getBag('default')?->toArray() ?? [], ]); @@ -105,34 +109,29 @@ public function authorizeInstance(Request $request): Response } /** - * Handle OAuth callback + * Handle the OAuth callback. + * + * Everything the flow needs is captured into locals before the session is + * cleared, so every exit below is free of cleanup. */ public function callback(Request $request): InertiaResponse { - $workspaceId = session('social_connect_workspace'); $savedState = session('mastodon_oauth_state'); $instance = session('mastodon_instance'); $clientId = session('mastodon_client_id'); $clientSecret = session('mastodon_client_secret'); - if (! $workspaceId || ! $instance) { + if (! $instance) { $this->clearMastodonSession(); - return $this->popupCallback(false, __('accounts.popup_callback.session_expired'), $this->platform->value); + throw new ConnectPopupException('session_expired', $this->platform); } + $this->clearMastodonSession(); + $workspace = $this->connectWorkspace($request); + if ($request->state !== $savedState) { - $this->clearMastodonSession(); - - return $this->popupCallback(false, __('accounts.popup_callback.invalid_state'), $this->platform->value); - } - - $workspace = Workspace::find($workspaceId); - - if (! $workspace || ! $request->user()->can('manageAccounts', $workspace)) { - $this->clearMastodonSession(); - - return $this->popupCallback(false, __('accounts.popup_callback.workspace_not_found'), $this->platform->value); + throw new ConnectPopupException('invalid_state', $this->platform); } try { @@ -177,12 +176,12 @@ public function callback(Request $request): InertiaResponse // verify required scopes (write:statuses, write:media) before // attempting to post. $grantedScopes = array_values(array_filter(explode(' ', (string) data_get($tokenData, 'scope', self::SCOPES)))); + $reconnect = $this->reconnectAccount($workspace); - $workspace->socialAccounts()->updateOrCreate( - [ - 'platform' => $this->platform->value, - 'platform_user_id' => data_get($profile, 'id'), - ], + SocialAccount::connectIdentity( + $workspace, + $this->platform, + (string) data_get($profile, 'id'), [ 'username' => data_get($profile, 'acct'), 'display_name' => data_get($profile, 'display_name') ?: data_get($profile, 'username'), @@ -200,22 +199,26 @@ public function callback(Request $request): InertiaResponse 'client_secret' => $clientSecret, ], ], + $reconnect, ); - $this->clearMastodonSession(); - - return $this->popupCallback(true, __('accounts.popup_callback.connected'), $this->platform->value); + return $this->connectedCallback($reconnect); + } catch (NetworkAlreadyConnectedException $e) { + return $this->popupCallback(false, __("accounts.popup_callback.{$e->messageKey}"), $this->platform->value); } catch (\Exception $e) { Log::error('Mastodon callback error', [ 'error' => $e->getMessage(), 'trace' => $e->getTraceAsString(), ]); - $this->clearMastodonSession(); return $this->popupCallback(false, __('accounts.popup_callback.error_connecting'), $this->platform->value); } } + /** + * Only the Mastodon-specific keys: the shared connect session is cleared by + * whatever closes the popup. + */ private function clearMastodonSession(): void { session()->forget([ @@ -223,7 +226,6 @@ private function clearMastodonSession(): void 'mastodon_client_id', 'mastodon_client_secret', 'mastodon_oauth_state', - 'social_connect_workspace', ]); } } diff --git a/app/Http/Controllers/Auth/PinterestController.php b/app/Http/Controllers/Auth/PinterestController.php index 9da9b746..2302e9b4 100644 --- a/app/Http/Controllers/Auth/PinterestController.php +++ b/app/Http/Controllers/Auth/PinterestController.php @@ -6,7 +6,8 @@ use App\Enums\SocialAccount\Platform as SocialPlatform; use App\Enums\SocialAccount\Status; -use App\Models\Workspace; +use App\Exceptions\SocialAccount\NetworkAlreadyConnectedException; +use App\Models\SocialAccount; use Illuminate\Http\Request; use Illuminate\Support\Facades\Log; use Inertia\Response as InertiaResponse; @@ -40,39 +41,37 @@ public function connect(Request $request): Response public function callback(Request $request): InertiaResponse { - $workspaceId = session('social_connect_workspace'); - - if (! $workspaceId) { - return $this->popupCallback(false, __('accounts.popup_callback.session_expired'), $this->platform->value); - } - - $workspace = Workspace::find($workspaceId); - - if (! $workspace || ! $request->user()->can('manageAccounts', $workspace)) { - return $this->popupCallback(false, __('accounts.popup_callback.workspace_not_found'), $this->platform->value); - } + $workspace = $this->connectWorkspace($request); try { $socialUser = Socialite::driver($this->driver)->user(); $avatarPath = uploadFromUrl($socialUser->getAvatar()); + $reconnect = $this->reconnectAccount($workspace); - // Create new account - $workspace->socialAccounts()->create([ - 'platform' => $this->platform->value, - 'platform_user_id' => $socialUser->getId(), - 'username' => $socialUser->getNickname(), - 'display_name' => $socialUser->getName() ?? $socialUser->getNickname(), - 'avatar_url' => $avatarPath, - 'access_token' => $socialUser->token, - 'refresh_token' => $socialUser->refreshToken, - 'token_expires_at' => $socialUser->expiresIn ? now()->addSeconds($socialUser->expiresIn) : now()->addDays(30), - // Pinterest returns scopes space-joined but Socialite doesn't split them, so re-split here. - 'scopes' => explode(' ', implode(' ', $socialUser->approvedScopes)), - 'status' => Status::Connected, - ]); + SocialAccount::connectIdentity( + $workspace, + $this->platform, + $socialUser->getId(), + [ + 'username' => $socialUser->getNickname(), + 'display_name' => $socialUser->getName() ?? $socialUser->getNickname(), + 'avatar_url' => $avatarPath, + 'access_token' => $socialUser->token, + 'refresh_token' => $socialUser->refreshToken, + 'token_expires_at' => $socialUser->expiresIn ? now()->addSeconds($socialUser->expiresIn) : now()->addDays(30), + // Pinterest returns scopes space-joined but Socialite doesn't split them, so re-split here. + 'scopes' => explode(' ', implode(' ', $socialUser->approvedScopes)), + 'status' => Status::Connected, + 'error_message' => null, + 'disconnected_at' => null, + ], + $reconnect, + ); - return $this->popupCallback(true, __('accounts.popup_callback.connected'), $this->platform->value); + return $this->connectedCallback($reconnect); + } catch (NetworkAlreadyConnectedException $e) { + return $this->popupCallback(false, __("accounts.popup_callback.{$e->messageKey}"), $this->platform->value); } catch (\Exception $e) { Log::error('Pinterest OAuth Error', [ 'error' => $e->getMessage(), diff --git a/app/Http/Controllers/Auth/SocialController.php b/app/Http/Controllers/Auth/SocialController.php index a2793ec9..375dae5c 100644 --- a/app/Http/Controllers/Auth/SocialController.php +++ b/app/Http/Controllers/Auth/SocialController.php @@ -8,6 +8,7 @@ use App\Enums\PostPlatform\Status as PostPlatformStatus; use App\Enums\SocialAccount\Platform as SocialPlatform; use App\Enums\SocialAccount\Status; +use App\Exceptions\SocialAccount\ConnectPopupException; use App\Exceptions\SocialAccount\NetworkAlreadyConnectedException; use App\Http\Controllers\Controller; use App\Http\Resources\App\SocialAccountResource; @@ -27,7 +28,7 @@ class SocialController extends Controller protected function ensurePlatformEnabled(): void { - if (isset($this->platform) && ! $this->platform->isEnabled()) { + if (! $this->platform->isEnabled()) { abort(SymfonyResponse::HTTP_FORBIDDEN, 'This platform is currently unavailable.'); } } @@ -91,11 +92,112 @@ public function toggleActive(Request $request, SocialAccount $account): Redirect return back(); } + /** + * The workspace the connect popup was opened for. + * + * @throws ConnectPopupException when the session is gone or the user may no + * longer manage the workspace's accounts. + */ + protected function connectWorkspace(Request $request): Workspace + { + $workspaceId = session('social_connect_workspace'); + + if (! $workspaceId) { + throw new ConnectPopupException('session_expired', $this->platform); + } + + $workspace = Workspace::find($workspaceId); + + if (! $workspace || ! $request->user()->can('manageAccounts', $workspace)) { + throw new ConnectPopupException('workspace_not_found', $this->platform); + } + + return $workspace; + } + + protected function rememberConnectSession(Request $request, Workspace $workspace): void + { + session([ + 'social_connect_workspace' => $workspace->id, + 'social_reconnect_id' => $this->validatedReconnectId($request, $workspace), + ]); + } + + /** + * The empty-string default keeps a missing query param from falling through + * to the session, which would let one network's reconnect leak into another. + */ + protected function validatedReconnectId(Request $request, Workspace $workspace): ?string + { + return $this->reconnectAccount($workspace, $request->query('reconnect', ''))?->id; + } + + protected function reconnectAccount(Workspace $workspace, mixed $reconnectId = null): ?SocialAccount + { + $reconnectId ??= session('social_reconnect_id'); + + if (! is_string($reconnectId) || $reconnectId === '') { + return null; + } + + return $workspace->socialAccounts() + ->whereIn('platform', $this->platform->networkPlatformValues()) + ->find($reconnectId); + } + + /** + * Nothing on this network is left to connect: the card being reconnected is + * gone from the provider, this login has nothing left to offer, or the + * single slot is taken. + */ + protected function noConnectableIdentities(?SocialAccount $reconnect, string $missingKey): Response + { + $key = match (true) { + $reconnect !== null => $missingKey, + (bool) config('trypost.allow_multiple_social_accounts') => 'all_connected', + default => 'network_taken', + }; + + return $this->popupCallback(false, __("accounts.popup_callback.{$key}"), $this->platform->value); + } + + /** + * Narrow the identities a provider returned to the ones this card may take. + * + * A reconnect only ever offers its own identity. Otherwise every identity + * already connected on this network is dropped — including in multi-account + * mode, where the same identity could otherwise be connected twice under two + * platforms of one network (Instagram directly and via Facebook). + * + * @param array> $identities + * @return array> + */ + protected function filterConnectableIdentities( + Workspace $workspace, + array $identities, + string $idKey, + ?SocialAccount $reconnect = null, + ): array { + $byId = collect($identities)->keyBy(fn (array $identity) => (string) data_get($identity, $idKey)); + $reconnect ??= $this->reconnectAccount($workspace); + + if ($reconnect) { + return $byId->only([(string) $reconnect->platform_user_id])->values()->all(); + } + + return $byId->except( + $workspace->socialAccounts() + ->whereIn('platform', $this->platform->networkPlatformValues()) + ->pluck('platform_user_id') + ->map(strval(...)), + )->values()->all(); + } + protected function redirectToProvider(Request $request, string $driver, array $scopes): SymfonyResponse { $workspace = $request->user()->currentWorkspace; - session(['social_connect_workspace' => $workspace->id]); + $this->rememberConnectSession($request, $workspace); return Inertia::location( Socialite::driver($driver) @@ -105,33 +207,20 @@ protected function redirectToProvider(Request $request, string $driver, array $s ); } - protected function handleCallback( - Request $request, - SocialPlatform $platform, - string $driver - ): Response { - $workspaceId = session('social_connect_workspace'); - - if (! $workspaceId) { - return $this->popupCallback(false, __('accounts.popup_callback.session_expired'), $platform->value); - } - - $workspace = Workspace::find($workspaceId); - - if (! $workspace || ! $request->user()->can('manageAccounts', $workspace)) { - return $this->popupCallback(false, __('accounts.popup_callback.workspace_not_found'), $platform->value); - } + protected function handleCallback(Request $request, string $driver): Response + { + $workspace = $this->connectWorkspace($request); try { $socialUser = Socialite::driver($driver)->user(); + $reconnect = $this->reconnectAccount($workspace); $avatarPath = uploadFromUrl($socialUser->getAvatar()); - $workspace->socialAccounts()->updateOrCreate( - [ - 'platform' => $platform->value, - 'platform_user_id' => $socialUser->getId(), - ], + SocialAccount::connectIdentity( + $workspace, + $this->platform, + $socialUser->getId(), [ 'username' => $socialUser->getNickname(), 'display_name' => $socialUser->getName(), @@ -144,24 +233,36 @@ protected function handleCallback( 'error_message' => null, 'disconnected_at' => null, ], + $reconnect, ); - return $this->popupCallback(true, __('accounts.popup_callback.connected'), $platform->value); - } catch (NetworkAlreadyConnectedException) { - return $this->popupCallback(false, __('accounts.popup_callback.network_taken'), $platform->value); + return $this->connectedCallback($reconnect); + } catch (NetworkAlreadyConnectedException $e) { + return $this->popupCallback(false, __("accounts.popup_callback.{$e->messageKey}"), $this->platform->value); } catch (\Exception $e) { Log::error('Social OAuth Error', [ - 'platform' => $platform->value, + 'platform' => $this->platform->value, 'error' => $e->getMessage(), ]); - return $this->popupCallback(false, __('accounts.popup_callback.error_connecting'), $platform->value); + return $this->popupCallback(false, __('accounts.popup_callback.error_connecting'), $this->platform->value); } } + /** + * Close the popup on a successful connect, wording it as a reconnect when + * the flow updated an existing card. + */ + protected function connectedCallback(?SocialAccount $reconnect): Response + { + return $this->popupCallback(true, $reconnect + ? __('accounts.popup_callback.reconnected') + : __('accounts.popup_callback.connected'), $this->platform->value); + } + protected function forgetSocialConnectSession(): void { - session()->forget('social_connect_workspace'); + session()->forget(['social_connect_workspace', 'social_reconnect_id']); } /** diff --git a/app/Http/Controllers/Auth/TelegramController.php b/app/Http/Controllers/Auth/TelegramController.php index 309a4606..c9c73316 100644 --- a/app/Http/Controllers/Auth/TelegramController.php +++ b/app/Http/Controllers/Auth/TelegramController.php @@ -30,7 +30,7 @@ public function connect(Request $request): JsonResponse $this->authorize('manageAccounts', $workspace); $expiresAt = now()->addMinutes(15); - $code = TelegramConnectCode::issue($workspace->id, $expiresAt); + $code = TelegramConnectCode::issue($workspace->id, $expiresAt, $this->validatedReconnectId($request, $workspace)); return response()->json([ 'code' => $code, diff --git a/app/Http/Controllers/Auth/ThreadsController.php b/app/Http/Controllers/Auth/ThreadsController.php index 955009ea..c2d1b8e3 100644 --- a/app/Http/Controllers/Auth/ThreadsController.php +++ b/app/Http/Controllers/Auth/ThreadsController.php @@ -6,8 +6,9 @@ use App\Enums\SocialAccount\Platform as SocialPlatform; use App\Enums\SocialAccount\Status; +use App\Exceptions\SocialAccount\ConnectPopupException; use App\Exceptions\SocialAccount\NetworkAlreadyConnectedException; -use App\Models\Workspace; +use App\Models\SocialAccount; use App\Services\Social\TokenRedactor; use Illuminate\Http\Request; use Illuminate\Support\Facades\Http; @@ -34,10 +35,7 @@ public function connect(Request $request): Response $this->authorize('manageAccounts', $workspace); - session([ - 'social_connect_workspace' => $workspace->id, - 'social_reconnect_id' => null, - ]); + $this->rememberConnectSession($request, $workspace); $state = bin2hex(random_bytes(16)); session(['threads_oauth_state' => $state]); @@ -55,27 +53,12 @@ public function connect(Request $request): Response public function callback(Request $request): InertiaResponse { - $workspaceId = session('social_connect_workspace'); $savedState = session('threads_oauth_state'); - - if (! $workspaceId) { - session()->forget(['threads_oauth_state', 'social_reconnect_id']); - - return $this->popupCallback(false, __('accounts.popup_callback.session_expired'), $this->platform->value); - } + session()->forget('threads_oauth_state'); + $workspace = $this->connectWorkspace($request); if ($request->state !== $savedState) { - session()->forget(['threads_oauth_state', 'social_reconnect_id']); - - return $this->popupCallback(false, __('accounts.popup_callback.invalid_state'), $this->platform->value); - } - - $workspace = Workspace::find($workspaceId); - - if (! $workspace || ! $request->user()->can('manageAccounts', $workspace)) { - session()->forget(['threads_oauth_state', 'social_reconnect_id']); - - return $this->popupCallback(false, __('accounts.popup_callback.workspace_not_found'), $this->platform->value); + throw new ConnectPopupException('invalid_state', $this->platform); } try { @@ -134,12 +117,12 @@ public function callback(Request $request): InertiaResponse $profile = $profileResponse->json(); $avatarPath = uploadFromUrl(data_get($profile, 'threads_profile_picture_url', null)); + $reconnect = $this->reconnectAccount($workspace); - $workspace->socialAccounts()->updateOrCreate( - [ - 'platform' => $this->platform->value, - 'platform_user_id' => data_get($profile, 'id'), - ], + SocialAccount::connectIdentity( + $workspace, + $this->platform, + (string) data_get($profile, 'id'), [ 'username' => data_get($profile, 'username'), 'display_name' => data_get($profile, 'name', data_get($profile, 'username')), @@ -152,21 +135,18 @@ public function callback(Request $request): InertiaResponse 'error_message' => null, 'disconnected_at' => null, ], + $reconnect, ); - session()->forget(['threads_oauth_state', 'social_reconnect_id']); - - return $this->popupCallback(true, __('accounts.popup_callback.connected'), $this->platform->value); - } catch (NetworkAlreadyConnectedException) { - return $this->popupCallback(false, __('accounts.popup_callback.network_taken'), $this->platform->value); + return $this->connectedCallback($reconnect); + } catch (NetworkAlreadyConnectedException $e) { + return $this->popupCallback(false, __("accounts.popup_callback.{$e->messageKey}"), $this->platform->value); } catch (\Exception $e) { Log::error('Threads OAuth Error', [ 'error' => $e->getMessage(), 'trace' => $e->getTraceAsString(), ]); - session()->forget(['threads_oauth_state', 'social_reconnect_id']); - return $this->popupCallback(false, __('accounts.popup_callback.error_connecting'), $this->platform->value); } } diff --git a/app/Http/Controllers/Auth/TikTokController.php b/app/Http/Controllers/Auth/TikTokController.php index 8c7a3a31..88b678a5 100644 --- a/app/Http/Controllers/Auth/TikTokController.php +++ b/app/Http/Controllers/Auth/TikTokController.php @@ -6,7 +6,8 @@ use App\Enums\SocialAccount\Platform as SocialPlatform; use App\Enums\SocialAccount\Status; -use App\Models\Workspace; +use App\Exceptions\SocialAccount\NetworkAlreadyConnectedException; +use App\Models\SocialAccount; use Illuminate\Http\Request; use Illuminate\Support\Facades\Log; use Inertia\Response as InertiaResponse; @@ -36,24 +37,12 @@ public function connect(Request $request): Response $this->authorize('manageAccounts', $workspace); - session(['social_reconnect_id' => null]); - return $this->redirectToProvider($request, $this->driver, $this->scopes); } public function callback(Request $request): InertiaResponse { - $workspaceId = session('social_connect_workspace'); - - if (! $workspaceId) { - return $this->popupCallback(false, __('accounts.popup_callback.session_expired'), $this->platform->value); - } - - $workspace = Workspace::find($workspaceId); - - if (! $workspace || ! $request->user()->can('manageAccounts', $workspace)) { - return $this->popupCallback(false, __('accounts.popup_callback.workspace_not_found'), $this->platform->value); - } + $workspace = $this->connectWorkspace($request); try { $socialUser = Socialite::driver($this->driver) @@ -63,12 +52,12 @@ public function callback(Request $request): InertiaResponse // TikTok returns username via getNickname() when user.info.profile scope is included $username = $socialUser->getNickname(); $avatarPath = uploadFromUrl($socialUser->getAvatar()); + $reconnect = $this->reconnectAccount($workspace); - $workspace->socialAccounts()->updateOrCreate( - [ - 'platform' => $this->platform->value, - 'platform_user_id' => $socialUser->getId(), - ], + SocialAccount::connectIdentity( + $workspace, + $this->platform, + $socialUser->getId(), [ 'username' => $username, 'display_name' => $socialUser->getName(), @@ -81,11 +70,12 @@ public function callback(Request $request): InertiaResponse 'error_message' => null, 'disconnected_at' => null, ], + $reconnect, ); - session()->forget('social_reconnect_id'); - - return $this->popupCallback(true, __('accounts.popup_callback.connected'), $this->platform->value); + return $this->connectedCallback($reconnect); + } catch (NetworkAlreadyConnectedException $e) { + return $this->popupCallback(false, __("accounts.popup_callback.{$e->messageKey}"), $this->platform->value); } catch (\Exception $e) { Log::error('TikTok OAuth Error', [ 'error' => $e->getMessage(), diff --git a/app/Http/Controllers/Auth/XController.php b/app/Http/Controllers/Auth/XController.php index 973f95ea..f6636c66 100644 --- a/app/Http/Controllers/Auth/XController.php +++ b/app/Http/Controllers/Auth/XController.php @@ -36,6 +36,6 @@ public function connect(Request $request): Response public function callback(Request $request): InertiaResponse { - return $this->handleCallback($request, $this->platform, $this->driver); + return $this->handleCallback($request, $this->driver); } } diff --git a/app/Http/Controllers/Auth/YouTubeController.php b/app/Http/Controllers/Auth/YouTubeController.php index bf691c62..13a77954 100644 --- a/app/Http/Controllers/Auth/YouTubeController.php +++ b/app/Http/Controllers/Auth/YouTubeController.php @@ -7,7 +7,7 @@ use App\Enums\SocialAccount\Platform as SocialPlatform; use App\Enums\SocialAccount\Status; use App\Exceptions\SocialAccount\NetworkAlreadyConnectedException; -use App\Models\Workspace; +use App\Models\SocialAccount; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Http; @@ -38,82 +38,70 @@ public function connect(Request $request): Response $this->authorize('manageAccounts', $workspace); - session([ - 'social_connect_workspace' => $workspace->id, - 'social_reconnect_id' => null, - ]); + $this->rememberConnectSession($request, $workspace); return $this->redirectToGoogle(); } public function callback(Request $request): InertiaResponse|RedirectResponse { - $workspaceId = session('social_connect_workspace'); + $workspace = $this->connectWorkspace($request); - if (! $workspaceId) { - return $this->popupCallback(false, __('accounts.popup_callback.session_expired'), $this->platform->value); - } - - $workspace = Workspace::find($workspaceId); - - if (! $workspace || ! $request->user()->can('manageAccounts', $workspace)) { - return $this->popupCallback(false, __('accounts.popup_callback.workspace_not_found'), $this->platform->value); - } + $reconnect = $this->reconnectAccount($workspace); try { $socialUser = Socialite::driver($this->driver)->user(); - // Fetch the channels the user authorized $channels = $this->fetchChannels($socialUser->token); if (empty($channels)) { return $this->popupCallback(false, __('accounts.popup_callback.no_youtube_channels'), $this->platform->value); } - // If only one channel, connect directly (most common case) - if (count($channels) === 1) { - $channel = $channels[0]; - $avatarPath = uploadFromUrl(data_get($channel, 'thumbnail')); + $channels = $this->filterConnectableIdentities($workspace, $channels, 'id', $reconnect); - $workspace->socialAccounts()->updateOrCreate( - [ - 'platform' => $this->platform->value, - 'platform_user_id' => data_get($channel, 'id'), - ], - [ - 'username' => ltrim(data_get($channel, 'custom_url', data_get($channel, 'id')), '@'), - 'display_name' => data_get($channel, 'title'), - 'avatar_url' => $avatarPath, - 'access_token' => $socialUser->token, - 'refresh_token' => $socialUser->refreshToken, - 'token_expires_at' => $socialUser->expiresIn ? now()->addSeconds($socialUser->expiresIn) : null, - 'scopes' => $this->scopes, - 'status' => Status::Connected, - 'error_message' => null, - 'disconnected_at' => null, - 'meta' => [ - 'channel_id' => data_get($channel, 'id'), - 'google_user_id' => $socialUser->getId(), - ], - ], - ); - - return $this->popupCallback(true, __('accounts.popup_callback.connected'), $this->platform->value); + if (empty($channels)) { + return $this->noConnectableIdentities($reconnect, 'channel_not_found'); } - // Multiple channels - store data and show selection screen - session([ - 'youtube_oauth' => [ + // Google's own delegation screen already made the user pick which + // channel this authorization is for, so channels?mine=true answers + // with that one. More than one only arrives if that ever changes. + if (count($channels) > 1) { + Log::warning('YouTube returned more than one channel for a delegated token', [ + 'channel_ids' => array_column($channels, 'id'), + ]); + } + + $channel = $channels[0]; + $avatarPath = uploadFromUrl(data_get($channel, 'thumbnail')); + + SocialAccount::connectIdentity( + $workspace, + $this->platform, + (string) data_get($channel, 'id'), + [ + 'username' => ltrim(data_get($channel, 'custom_url', data_get($channel, 'id')), '@'), + 'display_name' => data_get($channel, 'title'), + 'avatar_url' => $avatarPath, 'access_token' => $socialUser->token, 'refresh_token' => $socialUser->refreshToken, - 'expires_in' => $socialUser->expiresIn, - 'user_id' => $socialUser->getId(), + 'token_expires_at' => $socialUser->expiresIn ? now()->addSeconds($socialUser->expiresIn) : null, + 'scopes' => $this->scopes, + 'status' => Status::Connected, + 'error_message' => null, + 'disconnected_at' => null, + 'meta' => [ + 'channel_id' => data_get($channel, 'id'), + 'google_user_id' => $socialUser->getId(), + ], ], - ]); + $reconnect, + ); - return redirect()->route('app.social.youtube.select-channel'); - } catch (NetworkAlreadyConnectedException) { - return $this->popupCallback(false, __('accounts.popup_callback.network_taken'), $this->platform->value); + return $this->connectedCallback($reconnect); + } catch (NetworkAlreadyConnectedException $e) { + return $this->popupCallback(false, __("accounts.popup_callback.{$e->messageKey}"), $this->platform->value); } catch (\Exception $e) { Log::error('YouTube OAuth Error', [ 'error' => $e->getMessage(), @@ -124,131 +112,6 @@ public function callback(Request $request): InertiaResponse|RedirectResponse } } - public function selectChannel(Request $request): InertiaResponse - { - $oauthData = session('youtube_oauth'); - $workspaceId = session('social_connect_workspace'); - - if (! $oauthData || ! $workspaceId) { - return $this->popupCallback(false, __('accounts.popup_callback.session_expired'), $this->platform->value); - } - - $workspace = Workspace::find($workspaceId); - - if (! $workspace) { - return $this->popupCallback(false, __('accounts.popup_callback.workspace_not_found'), $this->platform->value); - } - - // Fetch YouTube channels - $channels = $this->fetchChannels(data_get($oauthData, 'access_token')); - - if (empty($channels)) { - $this->forgetSocialConnectSession(); - session()->forget('youtube_oauth'); - - return $this->popupCallback(false, __('accounts.popup_callback.no_youtube_channels'), $this->platform->value); - } - - return Inertia::render('accounts/YouTubeChannelSelect', [ - 'workspace' => $workspace, - 'channels' => $channels, - ]); - } - - public function select(Request $request): InertiaResponse - { - $request->validate([ - 'channel_id' => 'required|string', - ]); - - $oauthData = session('youtube_oauth'); - $workspaceId = session('social_connect_workspace'); - - if (! $oauthData || ! $workspaceId) { - return $this->popupCallback(false, __('accounts.popup_callback.session_expired'), $this->platform->value); - } - - $workspace = Workspace::find($workspaceId); - - if (! $workspace || ! $request->user()->can('manageAccounts', $workspace)) { - return $this->popupCallback(false, __('accounts.popup_callback.workspace_not_found'), $this->platform->value); - } - - try { - $channels = $this->fetchChannels(data_get($oauthData, 'access_token')); - $selectedChannel = collect($channels)->firstWhere('id', $request->channel_id); - - if (! $selectedChannel) { - return $this->popupCallback(false, __('accounts.popup_callback.channel_not_found'), $this->platform->value); - } - - $avatarPath = uploadFromUrl(data_get($selectedChannel, 'thumbnail')); - $reconnectId = data_get($oauthData, 'reconnect_id', null); - - if ($reconnectId) { - // Reconnect existing account - $existingAccount = $workspace->socialAccounts()->find($reconnectId); - - if ($existingAccount) { - $existingAccount->update([ - 'platform_user_id' => data_get($selectedChannel, 'id'), - 'username' => ltrim(data_get($selectedChannel, 'custom_url', data_get($selectedChannel, 'id')), '@'), - 'display_name' => data_get($selectedChannel, 'title'), - 'avatar_url' => $avatarPath, - 'access_token' => data_get($oauthData, 'access_token'), - 'refresh_token' => data_get($oauthData, 'refresh_token'), - 'token_expires_at' => data_get($oauthData, 'expires_in') ? now()->addSeconds(data_get($oauthData, 'expires_in')) : null, - 'scopes' => $this->scopes, - 'meta' => [ - 'channel_id' => data_get($selectedChannel, 'id'), - 'google_user_id' => data_get($oauthData, 'user_id'), - ], - ]); - $existingAccount->markAsConnected(); - - session()->forget(['youtube_oauth', 'social_reconnect_id']); - - return $this->popupCallback(true, __('accounts.popup_callback.reconnected'), $this->platform->value); - } - } - - $workspace->socialAccounts()->updateOrCreate( - [ - 'platform' => $this->platform->value, - 'platform_user_id' => data_get($selectedChannel, 'id'), - ], - [ - 'username' => ltrim(data_get($selectedChannel, 'custom_url', data_get($selectedChannel, 'id')), '@'), - 'display_name' => data_get($selectedChannel, 'title'), - 'avatar_url' => $avatarPath, - 'access_token' => data_get($oauthData, 'access_token'), - 'refresh_token' => data_get($oauthData, 'refresh_token'), - 'token_expires_at' => data_get($oauthData, 'expires_in') ? now()->addSeconds(data_get($oauthData, 'expires_in')) : null, - 'scopes' => $this->scopes, - 'status' => Status::Connected, - 'error_message' => null, - 'disconnected_at' => null, - 'meta' => [ - 'channel_id' => data_get($selectedChannel, 'id'), - 'google_user_id' => data_get($oauthData, 'user_id'), - ], - ], - ); - - session()->forget(['youtube_oauth', 'social_reconnect_id']); - - return $this->popupCallback(true, __('accounts.popup_callback.connected'), $this->platform->value); - } catch (NetworkAlreadyConnectedException) { - return $this->popupCallback(false, __('accounts.popup_callback.network_taken'), $this->platform->value); - } catch (\Exception $e) { - Log::error('YouTube channel selection error', [ - 'error' => $e->getMessage(), - ]); - - return $this->popupCallback(false, __('accounts.popup_callback.error_connecting_channel'), $this->platform->value); - } - } - private function redirectToGoogle(): Response { return Inertia::location( diff --git a/app/Http/Controllers/Webhooks/TelegramWebhookController.php b/app/Http/Controllers/Webhooks/TelegramWebhookController.php index d00e8717..f9205cd9 100644 --- a/app/Http/Controllers/Webhooks/TelegramWebhookController.php +++ b/app/Http/Controllers/Webhooks/TelegramWebhookController.php @@ -48,7 +48,12 @@ public function handle(Request $request): Response $workspace = $payload === null ? null : Workspace::find(data_get($payload, 'workspace_id')); if ($workspace !== null) { - ConnectTelegramChannel::execute($workspace, $chat, data_get($payload, 'nonce')); + ConnectTelegramChannel::execute( + $workspace, + $chat, + data_get($payload, 'nonce'), + data_get($payload, 'reconnect_id'), + ); } return response()->noContent(); diff --git a/app/Http/Middleware/App/HandleInertiaRequests.php b/app/Http/Middleware/App/HandleInertiaRequests.php index 756ed7f8..77a875e4 100644 --- a/app/Http/Middleware/App/HandleInertiaRequests.php +++ b/app/Http/Middleware/App/HandleInertiaRequests.php @@ -65,6 +65,7 @@ public function share(Request $request): array ])->values()->all(), 'aiEnabled' => filled(config('ai.providers.'.config('ai.default').'.key')), 'selfHosted' => $isSelfHosted, + 'allowMultipleSocialAccounts' => (bool) config('trypost.allow_multiple_social_accounts'), 'googleAuthEnabled' => SocialAuthProvider::Google->isEnabled(), 'githubAuthEnabled' => SocialAuthProvider::GitHub->isEnabled(), ]; diff --git a/app/Models/SocialAccount.php b/app/Models/SocialAccount.php index 61adc357..6059bb3e 100644 --- a/app/Models/SocialAccount.php +++ b/app/Models/SocialAccount.php @@ -6,12 +6,16 @@ use App\Enums\Notification\Channel; use App\Enums\Notification\Type; +use App\Enums\PostPlatform\ContentType; +use App\Enums\PostPlatform\Status as PostPlatformStatus; use App\Enums\SocialAccount\Platform as SocialPlatform; use App\Enums\SocialAccount\Status; +use App\Exceptions\SocialAccount\NetworkAlreadyConnectedException; use App\Jobs\SendNotification; use App\Mail\AccountDisconnected; use App\Observers\SocialAccountObserver; use Database\Factories\SocialAccountFactory; +use Illuminate\Contracts\Cache\LockTimeoutException; use Illuminate\Database\Eloquent\Attributes\ObservedBy; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Casts\Attribute; @@ -20,7 +24,9 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Database\UniqueConstraintViolationException; use Illuminate\Support\Facades\Cache; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Storage; #[ObservedBy(SocialAccountObserver::class)] @@ -81,6 +87,137 @@ public function workspace(): BelongsTo return $this->belongsTo(Workspace::class); } + public static function occupiesNetwork(string $workspaceId, SocialPlatform $platform): bool + { + return ! config('trypost.allow_multiple_social_accounts') + && static::query() + ->where('workspace_id', $workspaceId) + ->whereIn('platform', $platform->networkPlatformValues()) + ->exists(); + } + + /** + * Persist a freshly authorized identity. + * + * A reconnect only reuses its row when the provider returned the very same + * identity. Authorizing a different account is refused instead of repointing + * the card (and every post scheduled against it) at a stranger. + * + * @param array $values + */ + public static function connectIdentity( + Workspace $workspace, + SocialPlatform $platform, + string $platformUserId, + array $values, + ?self $reconnect = null, + ): self { + // The one-per-network rule is a config flag, so no database constraint + // can hold it and the observer's check-then-insert would let two popups + // finishing at once seat two different identities on one network. + try { + return Cache::lock("social_connect:{$workspace->id}:{$platform->network()}", 10) + ->block(5, fn (): self => static::persistIdentity( + $workspace, + $platform, + $platformUserId, + $values, + $reconnect, + )); + } catch (LockTimeoutException) { + throw NetworkAlreadyConnectedException::connectInProgress($platform); + } + } + + /** + * @param array $values + */ + private static function persistIdentity( + Workspace $workspace, + SocialPlatform $platform, + string $platformUserId, + array $values, + ?self $reconnect, + ): self { + $values['platform'] = $platform; + $values['platform_user_id'] = $platformUserId; + + $identity = [ + 'platform' => $platform->value, + 'platform_user_id' => $platformUserId, + ]; + + if ( + $reconnect?->workspace_id === $workspace->id + && $reconnect->platform->network() === $platform->network() + ) { + if ((string) $reconnect->platform_user_id !== $platformUserId) { + throw NetworkAlreadyConnectedException::identityMismatch($platform); + } + + $previousPlatform = $reconnect->platform; + + try { + // The card and the targets that still have to publish through it + // move together or not at all. + DB::transaction(function () use ($reconnect, $values, $previousPlatform, $platform): void { + $reconnect->update($values); + + static::realignUnpublishedTargets($reconnect, $previousPlatform, $platform); + }); + } catch (UniqueConstraintViolationException) { + throw new NetworkAlreadyConnectedException($platform); + } + + return $reconnect; + } + + try { + return $workspace->socialAccounts()->updateOrCreate($identity, $values); + } catch (UniqueConstraintViolationException) { + $account = $workspace->socialAccounts()->where($identity)->firstOrFail(); + $account->update($values); + + return $account; + } + } + + /** + * Reconnecting through the other variant of a network (Instagram directly + * after Facebook, a LinkedIn profile after its page) moves the card to the + * new platform. Post targets carry their own `platform` snapshot and that + * snapshot is what picks the publisher, the queue and the scopes checked + * before publishing, so a stale one fails the post on permissions it never + * needed. + * + * Only targets that still have a publish ahead of them move. Published rows + * record what really went out under a platform_post_id from that flavor of + * the API; failed ones are terminal; a publishing one has a job mid-flight + * that already read the snapshot it is working from. + */ + private static function realignUnpublishedTargets(self $account, SocialPlatform $from, SocialPlatform $to): void + { + if ($from === $to) { + return; + } + + $awaitingPublish = [PostPlatformStatus::Pending, PostPlatformStatus::Retrying]; + + $supported = array_values(array_map( + fn (ContentType $contentType): string => $contentType->value, + ContentType::forPlatform($to), + )); + + $account->postPlatforms() + ->whereIn('status', $awaitingPublish) + ->whereNotIn('content_type', $supported) + ->update(['content_type' => ContentType::defaultFor($to)->value]); + + $account->postPlatforms() + ->whereIn('status', $awaitingPublish) + ->update(['platform' => $to->value]); + } + public function postPlatforms(): HasMany { return $this->hasMany(PostPlatform::class); diff --git a/app/Models/Workspace.php b/app/Models/Workspace.php index 13981bc7..aba4944a 100644 --- a/app/Models/Workspace.php +++ b/app/Models/Workspace.php @@ -108,14 +108,4 @@ public function hasMember(User $user): bool { return $this->account?->owner_id === $user->id || $this->members()->where('user_id', $user->id)->exists(); } - - public function hasConnectedPlatform(string $platform): bool - { - return $this->socialAccounts()->where('platform', $platform)->exists(); - } - - public function getSocialAccount(string $platform): ?SocialAccount - { - return $this->socialAccounts()->where('platform', $platform)->first(); - } } diff --git a/app/Observers/SocialAccountObserver.php b/app/Observers/SocialAccountObserver.php index 13e5c9a6..c306a2a2 100644 --- a/app/Observers/SocialAccountObserver.php +++ b/app/Observers/SocialAccountObserver.php @@ -4,7 +4,7 @@ namespace App\Observers; -use App\Enums\SocialAccount\Platform; +use App\Enums\SocialAccount\Platform as SocialPlatform; use App\Enums\SocialAccount\Status; use App\Events\OnboardingStatusUpdated; use App\Exceptions\SocialAccount\NetworkAlreadyConnectedException; @@ -18,22 +18,17 @@ class SocialAccountObserver /** * Enforce one connected account per social network per workspace. Variants * of the same network (LinkedIn profile/page, Instagram standalone/Facebook) - * collapse via Platform::network(). Reconnecting an existing account goes - * through updateOrCreate's update path and never reaches this hook. Bypassed - * in self-hosted mode, which has no per-workspace limits. + * collapse via Platform::network(). Reconnecting an existing account updates + * the row and never reaches this hook. Bypassed when + * trypost.allow_multiple_social_accounts is true. */ public function creating(SocialAccount $socialAccount): void { - if (config('trypost.self_hosted') || ! $socialAccount->platform instanceof Platform) { + if (! $socialAccount->platform instanceof SocialPlatform) { return; } - $conflict = SocialAccount::query() - ->where('workspace_id', $socialAccount->workspace_id) - ->whereIn('platform', $socialAccount->platform->networkPlatformValues()) - ->exists(); - - if ($conflict) { + if (SocialAccount::occupiesNetwork((string) $socialAccount->workspace_id, $socialAccount->platform)) { throw new NetworkAlreadyConnectedException($socialAccount->platform); } } diff --git a/app/Services/Social/Telegram/TelegramConnectCode.php b/app/Services/Social/Telegram/TelegramConnectCode.php index c8ad463c..db5c393c 100644 --- a/app/Services/Social/Telegram/TelegramConnectCode.php +++ b/app/Services/Social/Telegram/TelegramConnectCode.php @@ -17,12 +17,13 @@ */ class TelegramConnectCode { - public static function issue(string $workspaceId, CarbonInterface $expiresAt): string + public static function issue(string $workspaceId, CarbonInterface $expiresAt, ?string $reconnectId = null): string { return Crypt::encryptString((string) json_encode([ 'workspace_id' => $workspaceId, 'nonce' => Str::lower(Str::random(16)), 'expires_at' => $expiresAt->getTimestamp(), + 'reconnect_id' => $reconnectId, ])); } @@ -30,7 +31,7 @@ public static function issue(string $workspaceId, CarbonInterface $expiresAt): s * Decode and validate a code, returning its payload or null when the code is * missing, tampered with, malformed, or expired. * - * @return array{workspace_id: string, nonce: string, expires_at: int}|null + * @return array{workspace_id: string, nonce: string, expires_at: int, reconnect_id: string|null}|null */ public static function decode(mixed $code): ?array { diff --git a/compose.prod.yaml b/compose.prod.yaml index f15bc57d..929d5cb5 100644 --- a/compose.prod.yaml +++ b/compose.prod.yaml @@ -21,6 +21,7 @@ services: APP_KEY: "" # <- run key:generate (see header) and paste here APP_URL: http://localhost:8000 # <- your public URL, e.g. https://post.yourdomain.com SELF_HOSTED: "true" + ALLOW_MULTIPLE_SOCIAL_ACCOUNTS: "true" TRYPOST_TARGET: production # ===== Database (bundled postgres service below) ===== diff --git a/config/trypost.php b/config/trypost.php index be8f0a55..a2e7cbd7 100644 --- a/config/trypost.php +++ b/config/trypost.php @@ -16,6 +16,26 @@ 'self_hosted' => env('SELF_HOSTED', true), + /* + |-------------------------------------------------------------------------- + | Multiple social accounts per network + |-------------------------------------------------------------------------- + | + | When false (Cloud default), a workspace may connect only one account + | per social network. Variants of the same network (LinkedIn profile/page, + | Instagram standalone/Facebook) count as one. Reconnecting the same + | identity (platform + platform_user_id) still updates the existing row. + | + | Independent of SELF_HOSTED so Cloud can flip this later without becoming + | self-hosted. Self-hosted installs typically set this true. + | + */ + + 'allow_multiple_social_accounts' => (bool) env( + 'ALLOW_MULTIPLE_SOCIAL_ACCOUNTS', + env('SELF_HOSTED', true), + ), + /* |-------------------------------------------------------------------------- | Security diff --git a/database/migrations/2026_08_21_130941_add_workspace_platform_identity_unique_to_social_accounts_table.php b/database/migrations/2026_08_21_130941_add_workspace_platform_identity_unique_to_social_accounts_table.php new file mode 100644 index 00000000..2eb9b1c9 --- /dev/null +++ b/database/migrations/2026_08_21_130941_add_workspace_platform_identity_unique_to_social_accounts_table.php @@ -0,0 +1,239 @@ +collapseDuplicateIdentities(); + + Schema::table('social_accounts', function (Blueprint $table) { + $table->unique( + ['workspace_id', 'platform', 'platform_user_id'], + 'social_accounts_workspace_platform_identity_unique', + ); + }); + } + + /** + * Drops the index only. The data merge in `up()` is one-way: the losing + * rows are gone, so rolling back leaves the collapsed identities collapsed. + * Every merge is logged at warning level so it can be reconstructed. + */ + public function down(): void + { + Schema::table('social_accounts', function (Blueprint $table) { + $table->dropUnique('social_accounts_workspace_platform_identity_unique'); + }); + } + + /** + * Installs that predate the unique index could store the same identity twice + * (the network guard was bypassed for multi-account installs, and Pinterest + * always created a fresh row). Keep the newest row per identity, move + * everything that points at the losers over to it, and drop them. + */ + private function collapseDuplicateIdentities(): void + { + $duplicates = DB::table('social_accounts') + ->select('workspace_id', 'platform', 'platform_user_id') + ->groupBy('workspace_id', 'platform', 'platform_user_id') + ->havingRaw('count(*) > 1') + ->get(); + + foreach ($duplicates as $duplicate) { + $ids = $this->newestFirst( + DB::table('social_accounts') + ->where('workspace_id', $duplicate->workspace_id) + ->where('platform', $duplicate->platform) + ->where('platform_user_id', $duplicate->platform_user_id) + )->pluck('id')->all(); + + $keepId = array_shift($ids); + + if ($keepId === null || $ids === []) { + continue; + } + + $repointed = DB::table('post_platforms') + ->whereIn('social_account_id', $ids) + ->update(['social_account_id' => $keepId]); + + $this->rewrittenAutomations = 0; + $this->repointAutomations((string) $duplicate->workspace_id, $ids, $keepId); + + DB::table('social_accounts')->whereIn('id', $ids)->delete(); + + $dropped = $this->dropRepeatedPostTargets($keepId); + + // Self-hosted installs run this unattended and it cannot be undone, + // so leave enough behind to reconstruct what happened. + Log::warning('Collapsed duplicate social accounts', [ + 'workspace_id' => $duplicate->workspace_id, + 'platform' => $duplicate->platform, + 'platform_user_id' => $duplicate->platform_user_id, + 'kept_id' => $keepId, + 'dropped_ids' => $ids, + 'post_platforms_repointed' => $repointed, + 'post_platforms_deleted' => $dropped, + 'automations_rewritten' => $this->rewrittenAutomations, + ]); + } + } + + /** + * Newest wins, with a total ordering so a rehearsal on a replica and the + * real run keep the same row. A null `created_at` sorts oldest on every + * engine rather than first on Postgres and last on MySQL. + */ + private function newestFirst(QueryBuilder $query): QueryBuilder + { + return $query + ->orderByRaw('case when created_at is null then 1 else 0 end') + ->orderByDesc('created_at') + ->orderByDesc('id'); + } + + /** + * A post could hold one row per duplicate account. Once they all point at + * the surviving account the post would publish to it once per row. + * + * Published rows are never touched: they record a post that is live on the + * network and carry the `platform_post_id` needed to manage it later, and + * two duplicate accounts really could each have published. Only the + * unpublished repeats collapse, preferring the row the user enabled - + * SyncPostPlatforms seeds a disabled row for every account in the + * workspace, so the usual duplicate is one row the user checked next to one + * they never saw, both pending and created in the same second. Keeping the + * disabled one would silently stop a scheduled post reaching that account. + */ + private function dropRepeatedPostTargets(string $keepId): int + { + $deleted = 0; + + $repeated = DB::table('post_platforms') + ->select('post_id') + ->where('social_account_id', $keepId) + ->groupBy('post_id') + ->havingRaw('count(*) > 1') + ->pluck('post_id'); + + foreach ($repeated as $postId) { + $target = fn (): QueryBuilder => DB::table('post_platforms') + ->where('social_account_id', $keepId) + ->where('post_id', $postId); + + $ids = $this->newestFirst( + $target() + ->where('status', '!=', 'published') + ->orderByRaw('case when enabled then 0 else 1 end') + )->pluck('id')->all(); + + // With a published row the content already went out, so every + // unpublished repeat is a second delivery waiting to happen - + // PostPlatform::scopeEnabled() filters on `enabled` alone. + if (! $target()->where('status', 'published')->exists()) { + array_shift($ids); + } + + if ($ids !== []) { + $deleted += DB::table('post_platforms')->whereIn('id', $ids)->delete(); + } + } + + return $deleted; + } + + /** + * Automation nodes persist `social_account_id` inside a JSON column with no + * foreign key, so a dropped account leaves the node pointing at nothing and + * RunGenerateNode quietly skips that target. Rewrite the ids and drop the + * entries that collapsing just turned into duplicates. + * + * @param array $droppedIds + */ + private function repointAutomations(string $workspaceId, array $droppedIds, string $keepId): void + { + $automations = DB::table('automations') + ->where('workspace_id', $workspaceId) + ->whereNotNull('nodes') + ->get(['id', 'nodes']); + + foreach ($automations as $automation) { + $nodes = json_decode((string) $automation->nodes, true); + + if (! is_array($nodes)) { + continue; + } + + $replaced = $this->replaceAccountIds($nodes, $droppedIds, $keepId); + + if ($replaced === $nodes) { + continue; + } + + DB::table('automations') + ->where('id', $automation->id) + ->update(['nodes' => json_encode($this->dedupeAccountEntries($replaced))]); + + $this->rewrittenAutomations++; + } + } + + /** + * Account ids are UUIDs, so matching on the value covers both the current + * `accounts[].social_account_id` shape and the legacy `social_account_ids` + * list without having to know where either sits in the tree. + * + * @param array $nodes + * @param array $droppedIds + * @return array + */ + private function replaceAccountIds(array $nodes, array $droppedIds, string $keepId): array + { + array_walk_recursive($nodes, function (mixed &$value) use ($droppedIds, $keepId): void { + if (is_string($value) && in_array($value, $droppedIds, true)) { + $value = $keepId; + } + }); + + return $nodes; + } + + /** + * @param array $value + * @return array + */ + private function dedupeAccountEntries(array $value): array + { + foreach ($value as $key => $child) { + if (! is_array($child)) { + continue; + } + + $value[$key] = $this->dedupeAccountEntries($child); + } + + if (isset($value['accounts']) && is_array($value['accounts'])) { + $value['accounts'] = array_values(collect($value['accounts']) + ->unique(fn (mixed $entry): string => (string) data_get($entry, 'social_account_id', '')) + ->all()); + } + + if (isset($value['social_account_ids']) && is_array($value['social_account_ids'])) { + $value['social_account_ids'] = array_values(array_unique($value['social_account_ids'])); + } + + return $value; + } +}; diff --git a/docker/.env.docker.example b/docker/.env.docker.example index 4266641a..60d7ce47 100644 --- a/docker/.env.docker.example +++ b/docker/.env.docker.example @@ -11,6 +11,9 @@ WEBHOOK_URL= # Self-hosted mode (skips payment requirements) SELF_HOSTED=true +# Allow more than one connected account per social network in a workspace. +ALLOW_MULTIPLE_SOCIAL_ACCOUNTS=true + TELESCOPE_ENABLED=false APP_LOCALE=en diff --git a/lang/ar/accounts.php b/lang/ar/accounts.php index 46971781..088618db 100644 --- a/lang/ar/accounts.php +++ b/lang/ar/accounts.php @@ -5,6 +5,7 @@ 'page_title' => 'الحسابات الاجتماعية', 'description' => 'نظرة عامة على جميع حساباتك الاجتماعية المتصلة', 'connect_cta' => 'ربط', + 'connect_another' => 'ربط حساب آخر', 'not_connected' => 'غير متصل', 'connect' => 'ربط', @@ -75,6 +76,8 @@ 'retry' => 'إعادة المحاولة', 'error_generic' => 'تعذر بدء الاتصال. يرجى المحاولة مرة أخرى.', 'network_taken' => 'تحتوي مساحة العمل هذه بالفعل على قناة Telegram متصلة. افصلها أولًا.', + 'wrong_chat' => 'انشر الأمر في القناة التي تعيد ربطها.', + 'busy' => 'لا يزال هناك اتصال آخر قيد الإنهاء. أعد إرسال الأمر بعد لحظات.', ], 'facebook' => [ @@ -140,6 +143,9 @@ 'reconnected' => 'تمت إعادة ربط الحساب!', 'error_connecting' => 'خطأ في ربط الحساب. يرجى المحاولة مرة أخرى.', 'network_taken' => 'تحتوي مساحة العمل هذه بالفعل على حساب لهذه الشبكة. افصله أولًا.', + 'wrong_account' => 'هذا حساب مختلف. صرّح بالحساب الذي تعيد ربطه.', + 'all_connected' => 'كل الحسابات في تسجيل الدخول هذا مرتبطة بالفعل.', + 'busy' => 'لا يزال هناك اتصال آخر قيد الإنهاء. يرجى المحاولة مرة أخرى بعد لحظات.', 'error_connecting_page' => 'خطأ في ربط الصفحة. يرجى المحاولة مرة أخرى.', 'error_connecting_channel' => 'خطأ في ربط القناة. يرجى المحاولة مرة أخرى.', 'session_expired' => 'انتهت الجلسة. يرجى المحاولة مرة أخرى.', diff --git a/lang/de/accounts.php b/lang/de/accounts.php index efb4801b..6bde74a5 100644 --- a/lang/de/accounts.php +++ b/lang/de/accounts.php @@ -7,6 +7,7 @@ 'page_title' => 'Social-Media-Konten', 'description' => 'Übersicht über alle deine verbundenen Social-Media-Konten', 'connect_cta' => 'Verbinden', + 'connect_another' => 'Weitere verbinden', 'not_connected' => 'Nicht verbunden', 'connect' => 'Verbinden', @@ -77,6 +78,8 @@ 'retry' => 'Erneut versuchen', 'error_generic' => 'Die Verbindung konnte nicht gestartet werden. Bitte versuche es erneut.', 'network_taken' => 'Dieser Workspace hat bereits einen verbundenen Telegram-Kanal. Trenne ihn zuerst.', + 'wrong_chat' => 'Poste den Befehl in dem Kanal, den du neu verbindest.', + 'busy' => 'Eine andere Verbindung wird noch abgeschlossen. Sende den Befehl gleich erneut.', ], 'facebook' => [ @@ -142,6 +145,9 @@ 'reconnected' => 'Konto erneut verbunden!', 'error_connecting' => 'Fehler beim Verbinden des Kontos. Bitte versuche es erneut.', 'network_taken' => 'Dieser Workspace hat bereits ein Konto für dieses Netzwerk. Trenne es zuerst.', + 'wrong_account' => 'Das ist ein anderes Konto. Autorisiere das Konto, das du neu verbindest.', + 'all_connected' => 'Alle Konten dieses Logins sind bereits verbunden.', + 'busy' => 'Eine andere Verbindung wird noch abgeschlossen. Bitte versuche es gleich erneut.', 'error_connecting_page' => 'Fehler beim Verbinden der Seite. Bitte versuche es erneut.', 'error_connecting_channel' => 'Fehler beim Verbinden des Kanals. Bitte versuche es erneut.', 'session_expired' => 'Sitzung abgelaufen. Bitte versuche es erneut.', diff --git a/lang/el/accounts.php b/lang/el/accounts.php index e94adbd1..1a853c8d 100644 --- a/lang/el/accounts.php +++ b/lang/el/accounts.php @@ -5,6 +5,7 @@ 'page_title' => 'Λογαριασμοί κοινωνικών δικτύων', 'description' => 'Επισκόπηση όλων των συνδεδεμένων λογαριασμών κοινωνικών δικτύων σας', 'connect_cta' => 'Σύνδεση', + 'connect_another' => 'Σύνδεση άλλου', 'not_connected' => 'Μη συνδεδεμένος', 'connect' => 'Σύνδεση', @@ -75,6 +76,8 @@ 'retry' => 'Δοκιμάστε ξανά', 'error_generic' => 'Δεν ήταν δυνατή η έναρξη της σύνδεσης. Παρακαλούμε δοκιμάστε ξανά.', 'network_taken' => 'Αυτό το workspace έχει ήδη συνδεδεμένο ένα κανάλι Telegram. Αποσυνδέστε το πρώτα.', + 'wrong_chat' => 'Δημοσιεύστε την εντολή στο κανάλι που επανασυνδέετε.', + 'busy' => 'Μια άλλη σύνδεση ολοκληρώνεται ακόμη. Στείλτε ξανά την εντολή σε λίγο.', ], 'facebook' => [ @@ -140,6 +143,9 @@ 'reconnected' => 'Ο λογαριασμός επανασυνδέθηκε!', 'error_connecting' => 'Σφάλμα κατά τη σύνδεση του λογαριασμού. Παρακαλούμε δοκιμάστε ξανά.', 'network_taken' => 'Αυτό το workspace έχει ήδη λογαριασμό για αυτό το δίκτυο. Αποσυνδέστε τον πρώτα.', + 'wrong_account' => 'Αυτός είναι διαφορετικός λογαριασμός. Εξουσιοδοτήστε αυτόν που επανασυνδέετε.', + 'all_connected' => 'Όλοι οι λογαριασμοί αυτής της σύνδεσης είναι ήδη συνδεδεμένοι.', + 'busy' => 'Μια άλλη σύνδεση ολοκληρώνεται ακόμη. Δοκιμάστε ξανά σε λίγο.', 'error_connecting_page' => 'Σφάλμα κατά τη σύνδεση της σελίδας. Παρακαλούμε δοκιμάστε ξανά.', 'error_connecting_channel' => 'Σφάλμα κατά τη σύνδεση του καναλιού. Παρακαλούμε δοκιμάστε ξανά.', 'session_expired' => 'Η συνεδρία έληξε. Παρακαλούμε δοκιμάστε ξανά.', diff --git a/lang/en/accounts.php b/lang/en/accounts.php index 6ab14552..893a4316 100644 --- a/lang/en/accounts.php +++ b/lang/en/accounts.php @@ -5,6 +5,7 @@ 'page_title' => 'Social Accounts', 'description' => 'Overview of all your connected social accounts', 'connect_cta' => 'Connect', + 'connect_another' => 'Connect another', 'not_connected' => 'Not connected', 'connect' => 'Connect', @@ -75,6 +76,8 @@ 'retry' => 'Try again', 'error_generic' => 'Could not start the connection. Please try again.', 'network_taken' => 'This workspace already has a Telegram channel connected. Disconnect it first.', + 'wrong_chat' => 'Post the command in the channel you are reconnecting.', + 'busy' => 'Another connection is still finishing. Post the command again in a moment.', ], 'facebook' => [ @@ -140,6 +143,9 @@ 'reconnected' => 'Account reconnected!', 'error_connecting' => 'Error connecting account. Please try again.', 'network_taken' => 'This workspace already has an account for this network. Disconnect it first.', + 'wrong_account' => 'That is a different account. Authorize the one you are reconnecting.', + 'all_connected' => 'Every account on this login is already connected.', + 'busy' => 'Another connection is still finishing. Please try again in a moment.', 'error_connecting_page' => 'Error connecting page. Please try again.', 'error_connecting_channel' => 'Error connecting channel. Please try again.', 'session_expired' => 'Session expired. Please try again.', diff --git a/lang/es/accounts.php b/lang/es/accounts.php index 38a93d01..78dac79b 100644 --- a/lang/es/accounts.php +++ b/lang/es/accounts.php @@ -5,6 +5,7 @@ 'page_title' => 'Cuentas Sociales', 'description' => 'Resumen de todas tus cuentas sociales conectadas', 'connect_cta' => 'Conectar', + 'connect_another' => 'Conectar otra', 'not_connected' => 'No conectado', 'connect' => 'Conectar', @@ -75,6 +76,8 @@ 'retry' => 'Reintentar', 'error_generic' => 'No se pudo iniciar la conexión. Inténtalo de nuevo.', 'network_taken' => 'Este workspace ya tiene un canal de Telegram conectado. Desconéctalo primero.', + 'wrong_chat' => 'Publica el comando en el canal que estás reconectando.', + 'busy' => 'Otra conexión aún se está completando. Vuelve a enviar el comando en un momento.', ], 'facebook' => [ @@ -140,6 +143,9 @@ 'reconnected' => '¡Cuenta reconectada!', 'error_connecting' => 'Error al conectar la cuenta. Inténtalo de nuevo.', 'network_taken' => 'Este workspace ya tiene una cuenta para esta red. Desconéctala primero.', + 'wrong_account' => 'Esa es una cuenta diferente. Autoriza la que estás reconectando.', + 'all_connected' => 'Todas las cuentas de este inicio de sesión ya están conectadas.', + 'busy' => 'Otra conexión aún se está completando. Inténtalo de nuevo en un momento.', 'error_connecting_page' => 'Error al conectar la página. Inténtalo de nuevo.', 'error_connecting_channel' => 'Error al conectar el canal. Inténtalo de nuevo.', 'session_expired' => 'Sesión expirada. Inténtalo de nuevo.', diff --git a/lang/fr/accounts.php b/lang/fr/accounts.php index 8e37fc69..e8d1c462 100644 --- a/lang/fr/accounts.php +++ b/lang/fr/accounts.php @@ -5,6 +5,7 @@ 'page_title' => 'Comptes sociaux', 'description' => 'Vue d\'ensemble de tous vos comptes sociaux connectés', 'connect_cta' => 'Connecter', + 'connect_another' => 'Connecter un autre', 'not_connected' => 'Non connecté', 'connect' => 'Connecter', @@ -75,6 +76,8 @@ 'retry' => 'Réessayer', 'error_generic' => 'Impossible de démarrer la connexion. Veuillez réessayer.', 'network_taken' => 'Cet espace de travail a déjà un canal Telegram connecté. Déconnectez-le d\'abord.', + 'wrong_chat' => 'Publiez la commande dans le canal que vous reconnectez.', + 'busy' => 'Une autre connexion est en cours de finalisation. Publiez à nouveau la commande dans un instant.', ], 'facebook' => [ @@ -140,6 +143,9 @@ 'reconnected' => 'Compte reconnecté !', 'error_connecting' => 'Erreur lors de la connexion du compte. Veuillez réessayer.', 'network_taken' => 'Cet espace de travail a déjà un compte pour ce réseau. Déconnectez-le d\'abord.', + 'wrong_account' => 'C\'est un autre compte. Autorisez celui que vous reconnectez.', + 'all_connected' => 'Tous les comptes de cette connexion sont déjà connectés.', + 'busy' => 'Une autre connexion est en cours de finalisation. Veuillez réessayer dans un instant.', 'error_connecting_page' => 'Erreur lors de la connexion de la page. Veuillez réessayer.', 'error_connecting_channel' => 'Erreur lors de la connexion de la chaîne. Veuillez réessayer.', 'session_expired' => 'Session expirée. Veuillez réessayer.', diff --git a/lang/it/accounts.php b/lang/it/accounts.php index b362705a..54a95b79 100644 --- a/lang/it/accounts.php +++ b/lang/it/accounts.php @@ -5,6 +5,7 @@ 'page_title' => 'Account social', 'description' => 'Panoramica di tutti i tuoi account social collegati', 'connect_cta' => 'Collega', + 'connect_another' => 'Collega un altro', 'not_connected' => 'Non collegato', 'connect' => 'Collega', @@ -75,6 +76,8 @@ 'retry' => 'Riprova', 'error_generic' => 'Impossibile avviare il collegamento. Riprova.', 'network_taken' => 'Questo workspace ha già un canale Telegram collegato. Scollegalo prima.', + 'wrong_chat' => 'Pubblica il comando nel canale che stai ricollegando.', + 'busy' => 'Una connessione precedente è ancora in corso. Invia di nuovo il comando tra un istante.', ], 'facebook' => [ @@ -140,6 +143,9 @@ 'reconnected' => 'Account ricollegato!', 'error_connecting' => 'Errore durante il collegamento dell\'account. Riprova.', 'network_taken' => 'Questo workspace ha già un account per questa rete. Scollegalo prima.', + 'wrong_account' => 'Questo è un account diverso. Autorizza quello che stai ricollegando.', + 'all_connected' => 'Tutti gli account di questo accesso sono già collegati.', + 'busy' => 'Una connessione precedente è ancora in corso. Riprova tra un istante.', 'error_connecting_page' => 'Errore durante il collegamento della pagina. Riprova.', 'error_connecting_channel' => 'Errore durante il collegamento del canale. Riprova.', 'session_expired' => 'Sessione scaduta. Riprova.', diff --git a/lang/ja/accounts.php b/lang/ja/accounts.php index 4ca6e41d..2fbe1753 100644 --- a/lang/ja/accounts.php +++ b/lang/ja/accounts.php @@ -5,6 +5,7 @@ 'page_title' => 'ソーシャルアカウント', 'description' => '接続済みのソーシャルアカウントの一覧', 'connect_cta' => '接続', + 'connect_another' => '別のアカウントを接続', 'not_connected' => '未接続', 'connect' => '接続', @@ -75,6 +76,8 @@ 'retry' => 'もう一度試す', 'error_generic' => '接続を開始できませんでした。もう一度お試しください。', 'network_taken' => 'このワークスペースにはすでに Telegram チャンネルが接続されています。先に接続を解除してください。', + 'wrong_chat' => '再接続するチャンネルでコマンドを投稿してください。', + 'busy' => '別の接続がまだ完了していません。少し待ってからコマンドを再送信してください。', ], 'facebook' => [ @@ -140,6 +143,9 @@ 'reconnected' => 'アカウントを再接続しました!', 'error_connecting' => 'アカウントの接続中にエラーが発生しました。もう一度お試しください。', 'network_taken' => 'このワークスペースにはすでにこのネットワークのアカウントが接続されています。先に接続を解除してください。', + 'wrong_account' => '別のアカウントです。再接続するアカウントを認証してください。', + 'all_connected' => 'このログインのアカウントはすべて接続済みです。', + 'busy' => '別の接続がまだ完了していません。少し待ってからもう一度お試しください。', 'error_connecting_page' => 'ページの接続中にエラーが発生しました。もう一度お試しください。', 'error_connecting_channel' => 'チャンネルの接続中にエラーが発生しました。もう一度お試しください。', 'session_expired' => 'セッションの有効期限が切れました。もう一度お試しください。', diff --git a/lang/ko/accounts.php b/lang/ko/accounts.php index 89ad4b75..e41912ef 100644 --- a/lang/ko/accounts.php +++ b/lang/ko/accounts.php @@ -5,6 +5,7 @@ 'page_title' => '소셜 계정', 'description' => '연결된 모든 소셜 계정 개요', 'connect_cta' => '연결', + 'connect_another' => '다른 계정 연결', 'not_connected' => '연결 안 됨', 'connect' => '연결', @@ -75,6 +76,8 @@ 'retry' => '다시 시도', 'error_generic' => '연결을 시작할 수 없습니다. 다시 시도해 주세요.', 'network_taken' => '이 워크스페이스에는 이미 Telegram 채널이 연결되어 있습니다. 먼저 연결을 해제하세요.', + 'wrong_chat' => '다시 연결하려는 채널에 명령을 게시하세요.', + 'busy' => '다른 연결이 아직 완료되지 않았습니다. 잠시 후 명령을 다시 보내주세요.', ], 'facebook' => [ @@ -140,6 +143,9 @@ 'reconnected' => '계정이 다시 연결되었습니다!', 'error_connecting' => '계정 연결 중 오류가 발생했습니다. 다시 시도해 주세요.', 'network_taken' => '이 워크스페이스에는 이미 이 네트워크의 계정이 있습니다. 먼저 연결을 해제하세요.', + 'wrong_account' => '다른 계정입니다. 다시 연결하려는 계정을 인증하세요.', + 'all_connected' => '이 로그인의 모든 계정이 이미 연결되어 있습니다.', + 'busy' => '다른 연결이 아직 완료되지 않았습니다. 잠시 후 다시 시도해 주세요.', 'error_connecting_page' => '페이지 연결 중 오류가 발생했습니다. 다시 시도해 주세요.', 'error_connecting_channel' => '채널 연결 중 오류가 발생했습니다. 다시 시도해 주세요.', 'session_expired' => '세션이 만료되었습니다. 다시 시도해 주세요.', diff --git a/lang/nl/accounts.php b/lang/nl/accounts.php index c5c8f45e..aa69e8af 100644 --- a/lang/nl/accounts.php +++ b/lang/nl/accounts.php @@ -5,6 +5,7 @@ 'page_title' => 'Social accounts', 'description' => 'Overzicht van al je gekoppelde social accounts', 'connect_cta' => 'Koppelen', + 'connect_another' => 'Nog een koppelen', 'not_connected' => 'Niet gekoppeld', 'connect' => 'Koppelen', @@ -75,6 +76,8 @@ 'retry' => 'Opnieuw proberen', 'error_generic' => 'Kon de koppeling niet starten. Probeer het opnieuw.', 'network_taken' => 'Deze workspace heeft al een Telegram-kanaal gekoppeld. Koppel dat eerst los.', + 'wrong_chat' => 'Plaats de opdracht in het kanaal dat je opnieuw koppelt.', + 'busy' => 'Een andere koppeling wordt nog afgerond. Plaats de opdracht zo meteen opnieuw.', ], 'facebook' => [ @@ -140,6 +143,9 @@ 'reconnected' => 'Account opnieuw gekoppeld!', 'error_connecting' => 'Fout bij het koppelen van het account. Probeer het opnieuw.', 'network_taken' => 'Deze workspace heeft al een account voor dit netwerk. Koppel dat eerst los.', + 'wrong_account' => 'Dat is een ander account. Autoriseer het account dat je opnieuw koppelt.', + 'all_connected' => 'Alle accounts van deze login zijn al gekoppeld.', + 'busy' => 'Een andere koppeling wordt nog afgerond. Probeer het zo meteen opnieuw.', 'error_connecting_page' => 'Fout bij het koppelen van de pagina. Probeer het opnieuw.', 'error_connecting_channel' => 'Fout bij het koppelen van het kanaal. Probeer het opnieuw.', 'session_expired' => 'Sessie verlopen. Probeer het opnieuw.', diff --git a/lang/pl/accounts.php b/lang/pl/accounts.php index 0828de36..ed8d13e2 100644 --- a/lang/pl/accounts.php +++ b/lang/pl/accounts.php @@ -5,6 +5,7 @@ 'page_title' => 'Konta społecznościowe', 'description' => 'Przegląd wszystkich Twoich połączonych kont społecznościowych', 'connect_cta' => 'Połącz', + 'connect_another' => 'Połącz kolejne', 'not_connected' => 'Niepołączone', 'connect' => 'Połącz', @@ -75,6 +76,8 @@ 'retry' => 'Spróbuj ponownie', 'error_generic' => 'Nie udało się rozpocząć łączenia. Spróbuj ponownie.', 'network_taken' => 'Ta przestrzeń robocza ma już połączony kanał Telegram. Najpierw go rozłącz.', + 'wrong_chat' => 'Opublikuj polecenie w kanale, który ponownie łączysz.', + 'busy' => 'Inne łączenie wciąż się kończy. Wyślij polecenie ponownie za chwilę.', ], 'facebook' => [ @@ -140,6 +143,9 @@ 'reconnected' => 'Konto połączone ponownie!', 'error_connecting' => 'Błąd podczas łączenia konta. Spróbuj ponownie.', 'network_taken' => 'Ta przestrzeń robocza ma już konto dla tej sieci. Najpierw je rozłącz.', + 'wrong_account' => 'To inne konto. Autoryzuj to, które ponownie łączysz.', + 'all_connected' => 'Wszystkie konta z tego logowania są już połączone.', + 'busy' => 'Inne łączenie wciąż się kończy. Spróbuj ponownie za chwilę.', 'error_connecting_page' => 'Błąd podczas łączenia strony. Spróbuj ponownie.', 'error_connecting_channel' => 'Błąd podczas łączenia kanału. Spróbuj ponownie.', 'session_expired' => 'Sesja wygasła. Spróbuj ponownie.', diff --git a/lang/pt-BR/accounts.php b/lang/pt-BR/accounts.php index 4531dcdd..69a76812 100644 --- a/lang/pt-BR/accounts.php +++ b/lang/pt-BR/accounts.php @@ -5,6 +5,7 @@ 'page_title' => 'Contas Sociais', 'description' => 'Visão geral de todas as suas contas sociais conectadas', 'connect_cta' => 'Conectar', + 'connect_another' => 'Conectar outra', 'not_connected' => 'Não conectado', 'connect' => 'Conectar', @@ -75,6 +76,8 @@ 'retry' => 'Tentar novamente', 'error_generic' => 'Não foi possível iniciar a conexão. Tente novamente.', 'network_taken' => 'Este workspace já tem um canal de Telegram conectado. Desconecte-o primeiro.', + 'wrong_chat' => 'Publique o comando no canal que você está reconectando.', + 'busy' => 'Outra conexão ainda está sendo concluída. Envie o comando novamente em instantes.', ], 'facebook' => [ @@ -140,6 +143,9 @@ 'reconnected' => 'Conta reconectada!', 'error_connecting' => 'Erro ao conectar conta. Por favor, tente novamente.', 'network_taken' => 'Este workspace já tem uma conta para esta rede. Desconecte-a primeiro.', + 'wrong_account' => 'Essa é outra conta. Autorize a que você está reconectando.', + 'all_connected' => 'Todas as contas deste login já estão conectadas.', + 'busy' => 'Outra conexão ainda está sendo concluída. Tente novamente em instantes.', 'error_connecting_page' => 'Erro ao conectar página. Por favor, tente novamente.', 'error_connecting_channel' => 'Erro ao conectar canal. Por favor, tente novamente.', 'session_expired' => 'Sessão expirada. Por favor, tente novamente.', diff --git a/lang/ru/accounts.php b/lang/ru/accounts.php index 384a0c43..3fbd8690 100644 --- a/lang/ru/accounts.php +++ b/lang/ru/accounts.php @@ -5,6 +5,7 @@ 'page_title' => 'Социальные аккаунты', 'description' => 'Обзор всех подключённых социальных аккаунтов', 'connect_cta' => 'Подключить', + 'connect_another' => 'Подключить ещё', 'not_connected' => 'Не подключено', 'connect' => 'Подключить', @@ -75,6 +76,8 @@ 'retry' => 'Повторить попытку', 'error_generic' => 'Не удалось начать подключение. Попробуйте ещё раз.', 'network_taken' => 'К этому рабочему пространству уже подключён канал Telegram. Сначала отключите его.', + 'wrong_chat' => 'Отправьте команду в канал, который вы переподключаете.', + 'busy' => 'Другое подключение ещё завершается. Отправьте команду ещё раз через мгновение.', ], 'facebook' => [ @@ -140,6 +143,9 @@ 'reconnected' => 'Аккаунт переподключён!', 'error_connecting' => 'Ошибка при подключении аккаунта. Попробуйте ещё раз.', 'network_taken' => 'К этому рабочему пространству уже подключён аккаунт этой сети. Сначала отключите его.', + 'wrong_account' => 'Это другой аккаунт. Авторизуйте тот, который вы переподключаете.', + 'all_connected' => 'Все аккаунты этого входа уже подключены.', + 'busy' => 'Другое подключение ещё завершается. Попробуйте ещё раз через мгновение.', 'error_connecting_page' => 'Ошибка при подключении страницы. Попробуйте ещё раз.', 'error_connecting_channel' => 'Ошибка при подключении канала. Попробуйте ещё раз.', 'session_expired' => 'Сессия истекла. Попробуйте ещё раз.', diff --git a/lang/tr/accounts.php b/lang/tr/accounts.php index e65d7b88..0e6a1480 100644 --- a/lang/tr/accounts.php +++ b/lang/tr/accounts.php @@ -7,6 +7,7 @@ 'page_title' => 'Sosyal Hesaplar', 'description' => 'Bağlı tüm sosyal hesaplarınıza genel bakış', 'connect_cta' => 'Bağla', + 'connect_another' => 'Başka birini bağla', 'not_connected' => 'Bağlı değil', 'connect' => 'Bağla', @@ -77,6 +78,8 @@ 'retry' => 'Tekrar dene', 'error_generic' => 'Bağlantı başlatılamadı. Lütfen tekrar deneyin.', 'network_taken' => 'Bu çalışma alanında zaten bağlı bir Telegram kanalı var. Önce bağlantısını kesin.', + 'wrong_chat' => 'Komutu yeniden bağladığınız kanalda paylaşın.', + 'busy' => 'Başka bir bağlantı hâlâ tamamlanıyor. Komutu birazdan tekrar gönderin.', ], 'facebook' => [ @@ -142,6 +145,9 @@ 'reconnected' => 'Hesap yeniden bağlandı!', 'error_connecting' => 'Hesap bağlanırken hata oluştu. Lütfen tekrar deneyin.', 'network_taken' => 'Bu çalışma alanında bu ağa ait zaten bir hesap var. Önce bağlantısını kesin.', + 'wrong_account' => 'Bu farklı bir hesap. Yeniden bağladığınız hesabı yetkilendirin.', + 'all_connected' => 'Bu oturumdaki tüm hesaplar zaten bağlı.', + 'busy' => 'Başka bir bağlantı hâlâ tamamlanıyor. Lütfen birazdan tekrar deneyin.', 'error_connecting_page' => 'Sayfa bağlanırken hata oluştu. Lütfen tekrar deneyin.', 'error_connecting_channel' => 'Kanal bağlanırken hata oluştu. Lütfen tekrar deneyin.', 'session_expired' => 'Oturum süresi doldu. Lütfen tekrar deneyin.', diff --git a/lang/uk/accounts.php b/lang/uk/accounts.php index ae1a521f..b7d72f6f 100644 --- a/lang/uk/accounts.php +++ b/lang/uk/accounts.php @@ -5,6 +5,7 @@ 'page_title' => 'Соціальні акаунти', 'description' => 'Огляд усіх підключених соціальних акаунтів', 'connect_cta' => 'Підключити', + 'connect_another' => 'Підключити ще', 'not_connected' => 'Не підключено', 'connect' => 'Підключити', @@ -75,6 +76,8 @@ 'retry' => 'Спробувати ще раз', 'error_generic' => 'Не вдалося розпочати підключення. Спробуйте ще раз.', 'network_taken' => 'У цьому робочому просторі вже підключено канал Telegram. Спочатку від’єднайте його.', + 'wrong_chat' => 'Надішліть команду в канал, який ви перепідключаєте.', + 'busy' => 'Інше підключення ще завершується. Надішліть команду ще раз за мить.', ], 'facebook' => [ @@ -140,6 +143,9 @@ 'reconnected' => 'Акаунт перепідключено!', 'error_connecting' => 'Помилка підключення акаунта. Спробуйте ще раз.', 'network_taken' => 'У цьому робочому просторі вже є акаунт для цієї мережі. Спочатку від’єднайте його.', + 'wrong_account' => 'Це інший акаунт. Авторизуйте той, який ви перепідключаєте.', + 'all_connected' => 'Усі акаунти цього входу вже підключені.', + 'busy' => 'Інше підключення ще завершується. Спробуйте ще раз за мить.', 'error_connecting_page' => 'Помилка підключення сторінки. Спробуйте ще раз.', 'error_connecting_channel' => 'Помилка підключення каналу. Спробуйте ще раз.', 'session_expired' => 'Сесію завершено. Спробуйте ще раз.', diff --git a/lang/zh/accounts.php b/lang/zh/accounts.php index af05c84e..7ab84b91 100644 --- a/lang/zh/accounts.php +++ b/lang/zh/accounts.php @@ -5,6 +5,7 @@ 'page_title' => '社交账号', 'description' => '查看你所有已连接的社交账号', 'connect_cta' => '连接', + 'connect_another' => '连接另一个', 'not_connected' => '未连接', 'connect' => '连接', @@ -75,6 +76,8 @@ 'retry' => '重试', 'error_generic' => '无法启动连接,请重试。', 'network_taken' => '此工作区已连接了一个 Telegram 频道。请先断开该连接。', + 'wrong_chat' => '请在你要重新连接的频道中发送该命令。', + 'busy' => '另一个连接仍在完成中,请稍后重新发送该命令。', ], 'facebook' => [ @@ -140,6 +143,9 @@ 'reconnected' => '账号已重新连接!', 'error_connecting' => '连接账号时出错,请重试。', 'network_taken' => '此工作区已连接了该网络的账号。请先断开该连接。', + 'wrong_account' => '这是另一个账号。请授权你正在重新连接的那个。', + 'all_connected' => '此登录下的所有账号都已连接。', + 'busy' => '另一个连接仍在完成中,请稍后重试。', 'error_connecting_page' => '连接页面时出错,请重试。', 'error_connecting_channel' => '连接频道时出错,请重试。', 'session_expired' => '会话已过期,请重试。', diff --git a/phpunit.xml b/phpunit.xml index f06f15dd..97f74bc8 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -33,5 +33,6 @@ + diff --git a/resources/js/components/AppSidebar.vue b/resources/js/components/AppSidebar.vue index 29a0353e..bdc0e120 100644 --- a/resources/js/components/AppSidebar.vue +++ b/resources/js/components/AppSidebar.vue @@ -204,7 +204,6 @@ const bottomNavItems = computed(() => [ class="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground" data-test="sidebar-menu-button" data-testid="sidebar-workspace-menu" - dusk="sidebar-workspace-menu" > [
@@ -305,7 +303,6 @@ const bottomNavItems = computed(() => [ variant="destructive" size="sm" class="mt-2 w-full" - dusk="past-due-cta" > {{ $t('billing.past_due_notice.cta') }} diff --git a/resources/js/components/SocialAccountsGrid.vue b/resources/js/components/SocialAccountsGrid.vue deleted file mode 100644 index e2c661a0..00000000 --- a/resources/js/components/SocialAccountsGrid.vue +++ /dev/null @@ -1,366 +0,0 @@ - - - diff --git a/resources/js/components/accounts/InstagramConnectDialog.vue b/resources/js/components/accounts/InstagramConnectDialog.vue index 5a8d027c..313fda1f 100644 --- a/resources/js/components/accounts/InstagramConnectDialog.vue +++ b/resources/js/components/accounts/InstagramConnectDialog.vue @@ -37,7 +37,10 @@ const showsFacebook = () => props.methods.includes(Platform.InstagramFacebook);