Allow multiple social accounts per network via env (#286)
* 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 <cursoragent@cursor.com>
* 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 <cursoragent@cursor.com>
* 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 <cursoragent@cursor.com>
* 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 <cursoragent@cursor.com>
* 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 <cursoragent@cursor.com>
* 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 <cursoragent@cursor.com>
* 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 <cursoragent@cursor.com>
* 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 <cursoragent@cursor.com>
* 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 <cursoragent@cursor.com>
* fix: keep reconnect updates on the original social card
Co-authored-by: Cursor <cursoragent@cursor.com>
* 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 <paulo@castellanos.llc>
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
96ec995fcd
commit
91c3d86d86
107 changed files with 4775 additions and 1372 deletions
1
.env.ci
1
.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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
10
AGENTS.md
10
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:
|
||||
|
|
|
|||
28
CLAUDE.md
28
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
|
||||
|
||||
|
|
|
|||
|
|
@ -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,32 +25,45 @@ 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,
|
||||
],
|
||||
try {
|
||||
$account = SocialAccount::connectIdentity(
|
||||
$workspace,
|
||||
Platform::Telegram,
|
||||
$chatId,
|
||||
[
|
||||
'username' => $username,
|
||||
'display_name' => data_get($chat, 'title') ?? $username ?? "Telegram {$chatId}",
|
||||
|
|
@ -68,7 +82,16 @@ public static function execute(Workspace $workspace, array $chat, string $nonce)
|
|||
'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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<array{value: string, label: string, color: string, network: string, connect_methods?: list<string>}>
|
||||
* @return list<array{value: string, label: string, network: string, connect_methods?: list<string>}>
|
||||
*/
|
||||
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(),
|
||||
];
|
||||
|
||||
|
|
|
|||
43
app/Exceptions/SocialAccount/ConnectPopupException.php
Normal file
43
app/Exceptions/SocialAccount/ConnectPopupException.php
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Exceptions\SocialAccount;
|
||||
|
||||
use App\Enums\SocialAccount\Platform;
|
||||
use Illuminate\Contracts\Debug\ShouldntReport;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Abort a social connect flow and close the popup with a reason.
|
||||
*
|
||||
* Rendering lives on the exception so the session and permission guards that
|
||||
* open every connect action stay a single line instead of six.
|
||||
*
|
||||
* An expired popup session is a normal outcome, not an incident, so this never
|
||||
* reaches the error log.
|
||||
*/
|
||||
class ConnectPopupException extends RuntimeException implements ShouldntReport
|
||||
{
|
||||
public function __construct(
|
||||
public readonly string $messageKey,
|
||||
public readonly ?Platform $platform = null,
|
||||
) {
|
||||
parent::__construct("Social connect aborted: {$messageKey}");
|
||||
}
|
||||
|
||||
public function render(Request $request): Response
|
||||
{
|
||||
session()->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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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.");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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,12 +172,15 @@ 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'),
|
||||
SocialAccount::connectIdentity(
|
||||
$workspace,
|
||||
$this->platform,
|
||||
(string) 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,
|
||||
|
|
@ -202,34 +188,18 @@ private function connectInstagramAccount(Workspace $workspace, array $pageData,
|
|||
'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'),
|
||||
],
|
||||
];
|
||||
|
||||
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(
|
||||
[
|
||||
'platform' => $this->platform->value,
|
||||
'platform_user_id' => data_get($pageData, 'ig_id'),
|
||||
],
|
||||
array_merge($accountData, [
|
||||
'status' => Status::Connected,
|
||||
'error_message' => null,
|
||||
'disconnected_at' => null,
|
||||
]),
|
||||
$existingAccount,
|
||||
);
|
||||
|
||||
return $this->popupCallback(true, __('accounts.popup_callback.connected'), $this->platform->value);
|
||||
return $this->connectedCallback($existingAccount);
|
||||
}
|
||||
|
||||
private function fetchPagesWithInstagram(string $userToken): array
|
||||
|
|
|
|||
|
|
@ -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<string, mixed> $pending
|
||||
* @param array<string, mixed> $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,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,27 +41,19 @@ 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(),
|
||||
SocialAccount::connectIdentity(
|
||||
$workspace,
|
||||
$this->platform,
|
||||
$socialUser->getId(),
|
||||
[
|
||||
'username' => $socialUser->getNickname(),
|
||||
'display_name' => $socialUser->getName() ?? $socialUser->getNickname(),
|
||||
'avatar_url' => $avatarPath,
|
||||
|
|
@ -70,9 +63,15 @@ public function callback(Request $request): InertiaResponse
|
|||
// 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(),
|
||||
|
|
|
|||
|
|
@ -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<int, array<string, mixed>> $identities
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
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']);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,48 +38,48 @@ 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) {
|
||||
$channels = $this->filterConnectableIdentities($workspace, $channels, 'id', $reconnect);
|
||||
|
||||
if (empty($channels)) {
|
||||
return $this->noConnectableIdentities($reconnect, 'channel_not_found');
|
||||
}
|
||||
|
||||
// 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'));
|
||||
|
||||
$workspace->socialAccounts()->updateOrCreate(
|
||||
[
|
||||
'platform' => $this->platform->value,
|
||||
'platform_user_id' => data_get($channel, 'id'),
|
||||
],
|
||||
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'),
|
||||
|
|
@ -96,24 +96,12 @@ public function callback(Request $request): InertiaResponse|RedirectResponse
|
|||
'google_user_id' => $socialUser->getId(),
|
||||
],
|
||||
],
|
||||
$reconnect,
|
||||
);
|
||||
|
||||
return $this->popupCallback(true, __('accounts.popup_callback.connected'), $this->platform->value);
|
||||
}
|
||||
|
||||
// Multiple channels - store data and show selection screen
|
||||
session([
|
||||
'youtube_oauth' => [
|
||||
'access_token' => $socialUser->token,
|
||||
'refresh_token' => $socialUser->refreshToken,
|
||||
'expires_in' => $socialUser->expiresIn,
|
||||
'user_id' => $socialUser->getId(),
|
||||
],
|
||||
]);
|
||||
|
||||
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(
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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<string, mixed> $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<string, mixed> $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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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) =====
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -0,0 +1,239 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Query\Builder as QueryBuilder;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
private int $rewrittenAutomations = 0;
|
||||
|
||||
public function up(): void
|
||||
{
|
||||
$this->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<int, string> $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<mixed> $nodes
|
||||
* @param array<int, string> $droppedIds
|
||||
* @return array<mixed>
|
||||
*/
|
||||
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<mixed> $value
|
||||
* @return array<mixed>
|
||||
*/
|
||||
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;
|
||||
}
|
||||
};
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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' => 'انتهت الجلسة. يرجى المحاولة مرة أخرى.',
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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' => 'Η συνεδρία έληξε. Παρακαλούμε δοκιμάστε ξανά.',
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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' => 'セッションの有効期限が切れました。もう一度お試しください。',
|
||||
|
|
|
|||
|
|
@ -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' => '세션이 만료되었습니다. 다시 시도해 주세요.',
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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' => 'Сессия истекла. Попробуйте ещё раз.',
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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' => 'Сесію завершено. Спробуйте ще раз.',
|
||||
|
|
|
|||
|
|
@ -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' => '会话已过期,请重试。',
|
||||
|
|
|
|||
|
|
@ -33,5 +33,6 @@
|
|||
<env name="FILESYSTEM_DISK" value="local"/>
|
||||
<env name="TELESCOPE_ENABLED" value="false"/>
|
||||
<env name="NIGHTWATCH_ENABLED" value="false"/>
|
||||
<env name="ALLOW_MULTIPLE_SOCIAL_ACCOUNTS" value="false" force="true"/>
|
||||
</php>
|
||||
</phpunit>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
>
|
||||
<Avatar
|
||||
:src="currentWorkspace?.logo_url"
|
||||
|
|
@ -287,7 +286,6 @@ const bottomNavItems = computed(() => [
|
|||
|
||||
<div
|
||||
v-if="subscriptionPastDue"
|
||||
dusk="past-due-notice"
|
||||
class="mx-1 mb-1 rounded-md border-2 border-destructive bg-destructive/10 p-3"
|
||||
>
|
||||
<div class="flex items-center gap-2 text-destructive">
|
||||
|
|
@ -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') }}
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -1,366 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import { router } from '@inertiajs/vue3';
|
||||
import {
|
||||
IconAlertCircle,
|
||||
IconCheck,
|
||||
IconExternalLink,
|
||||
IconRefresh,
|
||||
IconTrash,
|
||||
} from '@tabler/icons-vue';
|
||||
import { trans } from 'laravel-vue-i18n';
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import { getInitials } from '@/composables/useInitials';
|
||||
import { useOAuthPopup } from '@/composables/useOAuthPopup';
|
||||
import { getPlatformLogo } from '@/composables/usePlatformLogo';
|
||||
import { toggle as toggleAccount } from '@/routes/app/accounts';
|
||||
import {
|
||||
SocialAccountStatus,
|
||||
type SocialAccountStatusValue,
|
||||
} from '@/types/social-account-status';
|
||||
|
||||
export interface SocialAccount {
|
||||
id: string;
|
||||
platform: string;
|
||||
platform_user_id: string;
|
||||
username: string;
|
||||
display_name: string;
|
||||
display_label: string;
|
||||
handle_label: string;
|
||||
avatar_url: string;
|
||||
status: SocialAccountStatusValue | null;
|
||||
is_active: boolean;
|
||||
error_message: string | null;
|
||||
}
|
||||
|
||||
export interface Platform {
|
||||
value: string;
|
||||
label: string;
|
||||
color: string;
|
||||
connected: boolean;
|
||||
account: SocialAccount | null;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
platforms: Platform[];
|
||||
showDisconnect?: boolean;
|
||||
showReconnect?: boolean;
|
||||
showViewProfile?: boolean;
|
||||
columns?: 2 | 3 | 4;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
showDisconnect: true,
|
||||
showReconnect: true,
|
||||
showViewProfile: true,
|
||||
columns: 4,
|
||||
});
|
||||
|
||||
const handleToggle = (accountId: string) => {
|
||||
router.put(
|
||||
toggleAccount.url(accountId),
|
||||
{},
|
||||
{
|
||||
preserveScroll: true,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const { openOAuthPopup } = useOAuthPopup(() => router.reload());
|
||||
|
||||
const gridClass = computed(() => {
|
||||
switch (props.columns) {
|
||||
case 2:
|
||||
return 'sm:grid-cols-2';
|
||||
case 3:
|
||||
return 'sm:grid-cols-2 lg:grid-cols-3';
|
||||
case 4:
|
||||
default:
|
||||
return 'sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4';
|
||||
}
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
disconnect: [accountId: string];
|
||||
}>();
|
||||
|
||||
const getProfileUrl = (
|
||||
platform: string,
|
||||
username: string | null,
|
||||
platformUserId: string | null = null,
|
||||
): string | null => {
|
||||
if (platform === 'facebook') {
|
||||
const identifier = username || platformUserId;
|
||||
return identifier ? `https://facebook.com/${identifier}` : null;
|
||||
}
|
||||
|
||||
if (!username) return null;
|
||||
|
||||
const urls: Record<string, string> = {
|
||||
linkedin: `https://linkedin.com/in/${username}`,
|
||||
'linkedin-page': `https://linkedin.com/company/${username}`,
|
||||
x: `https://x.com/${username}`,
|
||||
tiktok: `https://tiktok.com/@${username}`,
|
||||
instagram: `https://instagram.com/${username}`,
|
||||
'instagram-facebook': `https://instagram.com/${username}`,
|
||||
youtube: `https://youtube.com/@${username}`,
|
||||
threads: `https://threads.net/@${username}`,
|
||||
bluesky: `https://bsky.app/profile/${username}`,
|
||||
pinterest: `https://pinterest.com/${username}`,
|
||||
telegram: `https://t.me/${username}`,
|
||||
};
|
||||
return urls[platform] || null;
|
||||
};
|
||||
|
||||
const isDisconnected = (account: SocialAccount | null): boolean => {
|
||||
if (!account) return false;
|
||||
return (
|
||||
account.status === SocialAccountStatus.Disconnected ||
|
||||
account.status === SocialAccountStatus.TokenExpired
|
||||
);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="grid gap-4" :class="gridClass">
|
||||
<div
|
||||
v-for="platform in platforms"
|
||||
:key="platform.value"
|
||||
class="group relative overflow-hidden rounded-xl border bg-card transition-all hover:shadow-md"
|
||||
:class="{
|
||||
'border-green-500/30 bg-green-50/50 dark:bg-green-950/20':
|
||||
platform.connected &&
|
||||
!isDisconnected(platform.account) &&
|
||||
platform.account?.is_active,
|
||||
'border-red-500/30 bg-red-50/50 dark:bg-red-950/20':
|
||||
platform.connected &&
|
||||
(isDisconnected(platform.account) ||
|
||||
!platform.account?.is_active),
|
||||
}"
|
||||
>
|
||||
<!-- Platform Header -->
|
||||
<div class="flex items-center gap-3 p-4">
|
||||
<div class="relative">
|
||||
<img
|
||||
:src="getPlatformLogo(platform.value)"
|
||||
:alt="platform.label"
|
||||
class="h-10 w-10 rounded-full object-contain"
|
||||
:class="{
|
||||
'opacity-40':
|
||||
platform.connected &&
|
||||
platform.account &&
|
||||
!platform.account.is_active,
|
||||
}"
|
||||
/>
|
||||
<div
|
||||
v-if="
|
||||
platform.connected &&
|
||||
!isDisconnected(platform.account)
|
||||
"
|
||||
class="absolute -right-0.5 -bottom-0.5 flex h-4 w-4 items-center justify-center rounded-full bg-green-500 text-white ring-2 ring-white dark:ring-neutral-900"
|
||||
>
|
||||
<IconCheck class="h-2 w-2" />
|
||||
</div>
|
||||
<div
|
||||
v-else-if="
|
||||
platform.connected &&
|
||||
isDisconnected(platform.account)
|
||||
"
|
||||
class="absolute -right-0.5 -bottom-0.5 flex h-4 w-4 items-center justify-center rounded-full bg-red-500 text-white ring-2 ring-white dark:ring-neutral-900"
|
||||
>
|
||||
<IconAlertCircle class="h-2 w-2" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<div class="flex min-w-0 items-center gap-1">
|
||||
<h3 class="leading-tight font-semibold">
|
||||
<template v-if="platform.label.includes('(')">
|
||||
{{ platform.label.split('(')[0].trim()
|
||||
}}<br />
|
||||
<span
|
||||
class="text-xs font-normal text-muted-foreground"
|
||||
>({{
|
||||
platform.label.split('(')[1]
|
||||
}}</span
|
||||
>
|
||||
</template>
|
||||
<template v-else>{{ platform.label }}</template>
|
||||
</h3>
|
||||
</div>
|
||||
<Switch
|
||||
v-if="platform.connected && platform.account"
|
||||
:model-value="platform.account.is_active"
|
||||
@update:model-value="
|
||||
handleToggle(platform.account.id)
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<p
|
||||
v-if="platform.connected && platform.account"
|
||||
class="truncate text-sm text-muted-foreground"
|
||||
>
|
||||
@{{ platform.account.handle_label }}
|
||||
</p>
|
||||
<p v-else class="text-sm text-muted-foreground">
|
||||
{{ trans('accounts.not_connected') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Connected State -->
|
||||
<div
|
||||
v-if="platform.connected && platform.account"
|
||||
class="border-t px-4 py-3"
|
||||
>
|
||||
<!-- Disconnected Warning -->
|
||||
<div
|
||||
v-if="isDisconnected(platform.account)"
|
||||
class="mb-3 flex items-start gap-2 rounded-lg bg-red-100 p-2 text-sm text-red-700 dark:bg-red-900/30 dark:text-red-400"
|
||||
>
|
||||
<IconAlertCircle class="mt-0.5 h-4 w-4 shrink-0" />
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="font-medium">
|
||||
{{ trans('accounts.connection_lost') }}
|
||||
</p>
|
||||
<p
|
||||
v-if="platform.account.error_message"
|
||||
class="truncate text-xs opacity-80"
|
||||
>
|
||||
{{ platform.account.error_message }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<Avatar class="h-8 w-8">
|
||||
<AvatarImage
|
||||
v-if="platform.account.avatar_url"
|
||||
:src="platform.account.avatar_url"
|
||||
/>
|
||||
<AvatarFallback class="text-xs">
|
||||
{{ getInitials(platform.account.display_label) }}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span
|
||||
class="max-w-[120px] truncate text-sm font-medium"
|
||||
>
|
||||
{{ platform.account.display_label }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<!-- Reconnect button for disconnected accounts -->
|
||||
<TooltipProvider
|
||||
v-if="
|
||||
showReconnect &&
|
||||
isDisconnected(platform.account)
|
||||
"
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="size-8 text-amber-600 hover:text-amber-700"
|
||||
@click="openOAuthPopup(platform.value)"
|
||||
>
|
||||
<IconRefresh class="size-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>
|
||||
{{
|
||||
trans('accounts.reconnect_account')
|
||||
}}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<TooltipProvider
|
||||
v-if="
|
||||
showViewProfile &&
|
||||
getProfileUrl(
|
||||
platform.value,
|
||||
platform.account.username,
|
||||
platform.account.platform_user_id,
|
||||
)
|
||||
"
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="size-8"
|
||||
as-child
|
||||
>
|
||||
<a
|
||||
:href="
|
||||
getProfileUrl(
|
||||
platform.value,
|
||||
platform.account.username,
|
||||
platform.account
|
||||
.platform_user_id,
|
||||
)!
|
||||
"
|
||||
target="_blank"
|
||||
>
|
||||
<IconExternalLink class="size-4" />
|
||||
</a>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{{ trans('accounts.view_profile') }}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<TooltipProvider v-if="showDisconnect">
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="size-8"
|
||||
@click="
|
||||
emit(
|
||||
'disconnect',
|
||||
platform.account.id,
|
||||
)
|
||||
"
|
||||
>
|
||||
<IconTrash class="size-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{{ trans('accounts.disconnect') }}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Not Connected State -->
|
||||
<div v-else class="border-t px-4 py-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
class="w-full"
|
||||
size="sm"
|
||||
@click="openOAuthPopup(platform.value)"
|
||||
>
|
||||
{{ trans('accounts.connect') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -37,7 +37,10 @@ const showsFacebook = () => props.methods.includes(Platform.InstagramFacebook);
|
|||
|
||||
<template>
|
||||
<Dialog v-model:open="open">
|
||||
<DialogContent class="sm:max-w-md" dusk="instagram-connect-dialog">
|
||||
<DialogContent
|
||||
class="sm:max-w-md"
|
||||
data-testid="instagram-connect-dialog"
|
||||
>
|
||||
<DialogHeader>
|
||||
<div class="flex items-start gap-3">
|
||||
<img
|
||||
|
|
@ -61,7 +64,7 @@ const showsFacebook = () => props.methods.includes(Platform.InstagramFacebook);
|
|||
v-if="showsStandalone()"
|
||||
variant="outline"
|
||||
class="h-auto justify-start gap-3 px-4 py-3 text-left whitespace-normal"
|
||||
dusk="instagram-connect-standalone"
|
||||
data-testid="instagram-connect-standalone"
|
||||
@click="choose(Platform.Instagram)"
|
||||
>
|
||||
<span
|
||||
|
|
@ -83,7 +86,7 @@ const showsFacebook = () => props.methods.includes(Platform.InstagramFacebook);
|
|||
v-if="showsFacebook()"
|
||||
variant="outline"
|
||||
class="h-auto justify-start gap-3 px-4 py-3 text-left whitespace-normal"
|
||||
dusk="instagram-connect-facebook"
|
||||
data-testid="instagram-connect-facebook"
|
||||
@click="choose(Platform.InstagramFacebook)"
|
||||
>
|
||||
<span
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
<script setup lang="ts">
|
||||
import { router } from '@inertiajs/vue3';
|
||||
import { router, usePage } from '@inertiajs/vue3';
|
||||
import { IconAlertTriangle, IconCheck } from '@tabler/icons-vue';
|
||||
import { trans } from 'laravel-vue-i18n';
|
||||
import { computed, ref } from 'vue';
|
||||
import { toast } from 'vue-sonner';
|
||||
|
||||
|
|
@ -9,7 +8,8 @@ import InstagramConnectDialog from '@/components/accounts/InstagramConnectDialog
|
|||
import TelegramConnectDialog from '@/components/accounts/TelegramConnectDialog.vue';
|
||||
import ConfirmDeleteModal from '@/components/ConfirmDeleteModal.vue';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useOAuthPopup } from '@/composables/useOAuthPopup';
|
||||
import { oauthConnectUrl, useOAuthPopup } from '@/composables/useOAuthPopup';
|
||||
import { getPlatformTheme } from '@/composables/usePlatformLogo';
|
||||
import { disconnect } from '@/routes/app/accounts';
|
||||
import { Platform } from '@/types/platform';
|
||||
import {
|
||||
|
|
@ -20,7 +20,6 @@ import {
|
|||
export interface AvailablePlatform {
|
||||
value: string;
|
||||
label: string;
|
||||
color: string;
|
||||
network: string;
|
||||
connect_methods?: string[];
|
||||
}
|
||||
|
|
@ -49,119 +48,10 @@ const props = withDefaults(
|
|||
},
|
||||
);
|
||||
|
||||
const getPlatformDescription = (platform: string): string =>
|
||||
trans(`accounts.descriptions.${platform}`);
|
||||
|
||||
// Mirrors `NetworksGrid.vue` from the marketing site — pastel tile bg
|
||||
// + ink 2px border + slight rotation per platform, real PNG logo inside.
|
||||
// `instagram-facebook` falls back to the base brand image and same color
|
||||
// since it's a variant of the same network.
|
||||
const platformTheme: Record<
|
||||
string,
|
||||
{ bg: string; rotate: string; image: string }
|
||||
> = {
|
||||
instagram: {
|
||||
bg: 'bg-pink-200',
|
||||
rotate: '-rotate-2',
|
||||
image: '/images/accounts/instagram.png',
|
||||
},
|
||||
'instagram-facebook': {
|
||||
bg: 'bg-pink-200',
|
||||
rotate: '-rotate-2',
|
||||
image: '/images/accounts/instagram.png',
|
||||
},
|
||||
facebook: {
|
||||
bg: 'bg-sky-200',
|
||||
rotate: 'rotate-1',
|
||||
image: '/images/accounts/facebook.png',
|
||||
},
|
||||
linkedin: {
|
||||
bg: 'bg-blue-200',
|
||||
rotate: '-rotate-1',
|
||||
image: '/images/accounts/linkedin.png',
|
||||
},
|
||||
x: {
|
||||
bg: 'bg-amber-200',
|
||||
rotate: 'rotate-2',
|
||||
image: '/images/accounts/x.png',
|
||||
},
|
||||
tiktok: {
|
||||
bg: 'bg-fuchsia-200',
|
||||
rotate: '-rotate-1',
|
||||
image: '/images/accounts/tiktok.png',
|
||||
},
|
||||
youtube: {
|
||||
bg: 'bg-red-200',
|
||||
rotate: 'rotate-1',
|
||||
image: '/images/accounts/youtube.png',
|
||||
},
|
||||
pinterest: {
|
||||
bg: 'bg-rose-200',
|
||||
rotate: '-rotate-2',
|
||||
image: '/images/accounts/pinterest.png',
|
||||
},
|
||||
threads: {
|
||||
bg: 'bg-emerald-200',
|
||||
rotate: 'rotate-2',
|
||||
image: '/images/accounts/threads.png',
|
||||
},
|
||||
bluesky: {
|
||||
bg: 'bg-cyan-200',
|
||||
rotate: '-rotate-1',
|
||||
image: '/images/accounts/bluesky.png',
|
||||
},
|
||||
mastodon: {
|
||||
bg: 'bg-violet-200',
|
||||
rotate: 'rotate-1',
|
||||
image: '/images/accounts/mastodon.png',
|
||||
},
|
||||
telegram: {
|
||||
bg: 'bg-sky-200',
|
||||
rotate: '-rotate-2',
|
||||
image: '/images/accounts/telegram.png',
|
||||
},
|
||||
discord: {
|
||||
bg: 'bg-indigo-200',
|
||||
rotate: 'rotate-1',
|
||||
image: '/images/accounts/discord.png',
|
||||
},
|
||||
};
|
||||
|
||||
const themeFor = (value: string) =>
|
||||
platformTheme[value] ?? { bg: 'bg-muted', rotate: '', image: '' };
|
||||
|
||||
// One account per network: map each connected network to its account so every
|
||||
// platform card belonging to that network reflects the connection.
|
||||
const connectedByNetwork = computed((): Record<string, ConnectedAccount> => {
|
||||
const map: Record<string, ConnectedAccount> = {};
|
||||
|
||||
for (const account of props.connectedAccounts) {
|
||||
if (!map[account.network]) {
|
||||
map[account.network] = account;
|
||||
}
|
||||
}
|
||||
|
||||
return map;
|
||||
});
|
||||
|
||||
// platform value -> the account occupying its network (if any).
|
||||
const cardConnection = computed(
|
||||
(): Record<string, ConnectedAccount | undefined> => {
|
||||
const map: Record<string, ConnectedAccount | undefined> = {};
|
||||
|
||||
for (const platform of props.platforms) {
|
||||
map[platform.value] = connectedByNetwork.value[platform.network];
|
||||
}
|
||||
|
||||
return map;
|
||||
},
|
||||
);
|
||||
|
||||
const telegramOpen = ref(false);
|
||||
const telegramReconnectId = ref<string>();
|
||||
const instagramOpen = ref(false);
|
||||
const disconnectModal = ref<InstanceType<typeof ConfirmDeleteModal> | null>(
|
||||
null,
|
||||
);
|
||||
const disconnectModal = ref<InstanceType<typeof ConfirmDeleteModal> | null>(null);
|
||||
|
||||
const { openOAuthPopup } = useOAuthPopup((result) => {
|
||||
if (result.success) {
|
||||
|
|
@ -173,6 +63,34 @@ const { openOAuthPopup } = useOAuthPopup((result) => {
|
|||
toast.error(result.message);
|
||||
});
|
||||
|
||||
const connectEntry = (platform: string): string =>
|
||||
platform === Platform.LinkedInPage ? Platform.LinkedIn : platform;
|
||||
|
||||
const openConnect = (platform: string, reconnectId?: string) => {
|
||||
const url = oauthConnectUrl(platform, reconnectId);
|
||||
|
||||
if (url) {
|
||||
openOAuthPopup(url);
|
||||
}
|
||||
};
|
||||
|
||||
const startConnect = (platform: string, reconnectId?: string) => {
|
||||
const entry = connectEntry(platform);
|
||||
|
||||
if (entry === Platform.Telegram) {
|
||||
telegramReconnectId.value = reconnectId;
|
||||
telegramOpen.value = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (entry === Platform.Instagram && !reconnectId) {
|
||||
instagramOpen.value = true;
|
||||
return;
|
||||
}
|
||||
|
||||
openConnect(entry, reconnectId);
|
||||
};
|
||||
|
||||
const disconnectAccount = (account: ConnectedAccount) => {
|
||||
disconnectModal.value?.open({
|
||||
url: disconnect.url(account.id),
|
||||
|
|
@ -180,81 +98,63 @@ const disconnectAccount = (account: ConnectedAccount) => {
|
|||
});
|
||||
};
|
||||
|
||||
const needsReconnect = (account: ConnectedAccount): boolean =>
|
||||
const instagramMethods = computed(
|
||||
() =>
|
||||
props.platforms.find((platform) => platform.value === Platform.Instagram)?.connect_methods ?? [
|
||||
Platform.Instagram,
|
||||
Platform.InstagramFacebook,
|
||||
],
|
||||
);
|
||||
|
||||
interface ConnectCard {
|
||||
key: string;
|
||||
platform: AvailablePlatform;
|
||||
account?: ConnectedAccount;
|
||||
theme: ReturnType<typeof getPlatformTheme>;
|
||||
title: string;
|
||||
state: 'connected' | 'reconnect' | 'connect';
|
||||
extra: boolean;
|
||||
}
|
||||
|
||||
const page = usePage();
|
||||
|
||||
const cards = computed<ConnectCard[]>(() => {
|
||||
const allowMultiple = Boolean(page.props.allowMultipleSocialAccounts);
|
||||
|
||||
return props.platforms.flatMap((platform) => {
|
||||
const accounts = props.connectedAccounts.filter((account) => account.network === platform.network);
|
||||
const theme = getPlatformTheme(platform.value);
|
||||
const title = platform.label.split('(')[0].trim();
|
||||
|
||||
const connected: ConnectCard[] = accounts.map((account) => {
|
||||
const lost =
|
||||
account.status === SocialAccountStatus.Disconnected ||
|
||||
account.status === SocialAccountStatus.TokenExpired;
|
||||
|
||||
const connectEntryFor = (platformValue: string): string =>
|
||||
platformValue === Platform.LinkedInPage ? Platform.LinkedIn : platformValue;
|
||||
|
||||
const instagramMethods = computed((): string[] => {
|
||||
const instagram = props.platforms.find(
|
||||
(platform) => platform.value === Platform.Instagram,
|
||||
);
|
||||
|
||||
return (
|
||||
instagram?.connect_methods ?? [
|
||||
Platform.Instagram,
|
||||
Platform.InstagramFacebook,
|
||||
]
|
||||
);
|
||||
return {
|
||||
key: account.id,
|
||||
platform,
|
||||
account,
|
||||
theme,
|
||||
title,
|
||||
state: lost ? 'reconnect' : 'connected',
|
||||
extra: false,
|
||||
};
|
||||
});
|
||||
|
||||
const openConnect = (platformValue: string) => {
|
||||
if (platformValue === Platform.Telegram) {
|
||||
telegramOpen.value = true;
|
||||
return;
|
||||
if (accounts.length === 0 || allowMultiple) {
|
||||
connected.push({
|
||||
key: `${platform.value}-connect`,
|
||||
platform,
|
||||
theme,
|
||||
title,
|
||||
state: 'connect',
|
||||
extra: accounts.length > 0,
|
||||
});
|
||||
}
|
||||
|
||||
if (platformValue === Platform.Instagram) {
|
||||
instagramOpen.value = true;
|
||||
return;
|
||||
}
|
||||
|
||||
openOAuthPopup(platformValue);
|
||||
};
|
||||
|
||||
const connectPlatform = (platformValue: string) => {
|
||||
if (cardConnection.value[platformValue]) {
|
||||
return;
|
||||
}
|
||||
|
||||
openConnect(platformValue);
|
||||
};
|
||||
|
||||
const reconnectAccount = (account: ConnectedAccount) => {
|
||||
const entry = connectEntryFor(account.platform);
|
||||
|
||||
if (entry === Platform.Telegram) {
|
||||
telegramOpen.value = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// Reconnect with the same OAuth method — skip the Instagram method picker.
|
||||
openOAuthPopup(entry);
|
||||
};
|
||||
|
||||
const CardState = {
|
||||
Connect: 'connect',
|
||||
Connected: 'connected',
|
||||
Reconnect: 'reconnect',
|
||||
} as const;
|
||||
|
||||
type CardStateValue = (typeof CardState)[keyof typeof CardState];
|
||||
|
||||
const cardState = computed((): Record<string, CardStateValue> => {
|
||||
const map: Record<string, CardStateValue> = {};
|
||||
|
||||
for (const platform of props.platforms) {
|
||||
const account = connectedByNetwork.value[platform.network];
|
||||
map[platform.value] = !account
|
||||
? CardState.Connect
|
||||
: needsReconnect(account)
|
||||
? CardState.Reconnect
|
||||
: CardState.Connected;
|
||||
}
|
||||
|
||||
return map;
|
||||
return connected;
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
|
|
@ -262,64 +162,66 @@ const cardState = computed((): Record<string, CardStateValue> => {
|
|||
<div>
|
||||
<div :class="['grid gap-4', gridClass]">
|
||||
<div
|
||||
v-for="platform in platforms"
|
||||
:key="platform.value"
|
||||
v-for="card in cards"
|
||||
:key="card.key"
|
||||
:class="[
|
||||
'group relative flex flex-col items-center gap-3 rounded-xl border-2 border-foreground p-4 text-center shadow-xs transition-shadow',
|
||||
cardState[platform.value] === CardState.Connected
|
||||
card.state === 'connected'
|
||||
? 'bg-emerald-50'
|
||||
: cardState[platform.value] === CardState.Reconnect
|
||||
: card.state === 'reconnect'
|
||||
? 'bg-amber-50'
|
||||
: 'bg-card hover:shadow-md',
|
||||
]"
|
||||
>
|
||||
<span
|
||||
v-if="cardState[platform.value] === CardState.Connected"
|
||||
class="absolute -top-2 -right-2 inline-flex size-6 items-center justify-center rounded-full border-2 border-foreground bg-emerald-200 text-emerald-700 shadow-2xs"
|
||||
v-if="card.state !== 'connect'"
|
||||
:class="[
|
||||
'absolute -top-2 -right-2 inline-flex size-6 items-center justify-center rounded-full border-2 border-foreground shadow-2xs',
|
||||
card.state === 'connected'
|
||||
? 'bg-emerald-200 text-emerald-700'
|
||||
: 'bg-amber-200 text-amber-700',
|
||||
]"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<IconCheck class="size-3.5" stroke-width="3" />
|
||||
</span>
|
||||
<span
|
||||
v-else-if="cardState[platform.value] === CardState.Reconnect"
|
||||
class="absolute -top-2 -right-2 inline-flex size-6 items-center justify-center rounded-full border-2 border-foreground bg-amber-200 text-amber-700 shadow-2xs"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<IconAlertTriangle class="size-3.5" stroke-width="2.5" />
|
||||
<IconCheck
|
||||
v-if="card.state === 'connected'"
|
||||
class="size-3.5"
|
||||
stroke-width="3"
|
||||
/>
|
||||
<IconAlertTriangle
|
||||
v-else
|
||||
class="size-3.5"
|
||||
stroke-width="2.5"
|
||||
/>
|
||||
</span>
|
||||
|
||||
<div
|
||||
:class="[
|
||||
themeFor(platform.value).bg,
|
||||
themeFor(platform.value).rotate,
|
||||
card.theme.bg,
|
||||
card.theme.rotate,
|
||||
'inline-flex size-16 items-center justify-center rounded-2xl border-2 border-foreground shadow-sm transition-transform group-hover:!rotate-0',
|
||||
]"
|
||||
>
|
||||
<img
|
||||
:src="themeFor(platform.value).image"
|
||||
:alt="platform.label"
|
||||
:src="card.theme.image"
|
||||
:alt="card.platform.label"
|
||||
class="size-9 rounded-lg"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="w-full min-w-0 flex-1">
|
||||
<span
|
||||
class="block truncate text-sm font-semibold text-foreground"
|
||||
>
|
||||
<template v-if="platform.label.includes('(')">
|
||||
{{ platform.label.split('(')[0].trim() }}
|
||||
</template>
|
||||
<template v-else>{{ platform.label }}</template>
|
||||
<span class="block truncate text-sm font-semibold text-foreground">
|
||||
{{ card.title }}
|
||||
</span>
|
||||
<p
|
||||
v-if="cardState[platform.value] === CardState.Connect"
|
||||
v-if="card.state === 'connect'"
|
||||
class="mt-0.5 line-clamp-2 text-xs leading-tight text-foreground/60"
|
||||
>
|
||||
{{ getPlatformDescription(platform.value) }}
|
||||
{{ $t(`accounts.descriptions.${card.platform.value}`) }}
|
||||
</p>
|
||||
<p
|
||||
v-else-if="cardState[platform.value] === CardState.Reconnect"
|
||||
v-else-if="card.state === 'reconnect'"
|
||||
class="mt-0.5 truncate text-xs leading-tight font-medium text-amber-700"
|
||||
>
|
||||
{{ $t('accounts.connection_lost') }}
|
||||
|
|
@ -328,24 +230,24 @@ const cardState = computed((): Record<string, CardStateValue> => {
|
|||
v-else
|
||||
class="mt-0.5 truncate text-xs leading-tight text-foreground/70"
|
||||
>
|
||||
{{ cardConnection[platform.value]?.display_label }}
|
||||
{{ card.account?.display_label }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
v-if="cardState[platform.value] === CardState.Reconnect"
|
||||
v-if="card.state === 'reconnect' && card.account"
|
||||
size="sm"
|
||||
class="mt-auto w-full"
|
||||
@click="reconnectAccount(cardConnection[platform.value]!)"
|
||||
@click="startConnect(card.account.platform, card.account.id)"
|
||||
>
|
||||
{{ $t('accounts.reconnect') }}
|
||||
</Button>
|
||||
<Button
|
||||
v-else-if="cardState[platform.value] === CardState.Connected"
|
||||
v-else-if="card.state === 'connected' && card.account"
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
class="mt-auto w-full"
|
||||
@click="disconnectAccount(cardConnection[platform.value]!)"
|
||||
@click="disconnectAccount(card.account)"
|
||||
>
|
||||
{{ $t('accounts.disconnect') }}
|
||||
</Button>
|
||||
|
|
@ -353,19 +255,27 @@ const cardState = computed((): Record<string, CardStateValue> => {
|
|||
v-else
|
||||
size="sm"
|
||||
class="mt-auto w-full"
|
||||
@click="connectPlatform(platform.value)"
|
||||
:data-testid="`connect-${card.platform.value}`"
|
||||
@click="startConnect(card.platform.value)"
|
||||
>
|
||||
{{ $t('accounts.connect_cta') }}
|
||||
{{
|
||||
card.extra
|
||||
? $t('accounts.connect_another')
|
||||
: $t('accounts.connect_cta')
|
||||
}}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TelegramConnectDialog v-model:open="telegramOpen" />
|
||||
<TelegramConnectDialog
|
||||
v-model:open="telegramOpen"
|
||||
:reconnect-id="telegramReconnectId"
|
||||
/>
|
||||
|
||||
<InstagramConnectDialog
|
||||
v-model:open="instagramOpen"
|
||||
:methods="instagramMethods"
|
||||
@select="openOAuthPopup"
|
||||
@select="openConnect"
|
||||
/>
|
||||
|
||||
<ConfirmDeleteModal
|
||||
|
|
|
|||
|
|
@ -26,6 +26,10 @@ import { connect as connectTelegram } from '@/routes/app/social/telegram';
|
|||
|
||||
const open = defineModel<boolean>('open', { required: true });
|
||||
|
||||
const props = defineProps<{
|
||||
reconnectId?: string;
|
||||
}>();
|
||||
|
||||
type Phase = 'loading' | 'ready' | 'connected' | 'expired' | 'error';
|
||||
|
||||
interface ConnectResponse {
|
||||
|
|
@ -72,6 +76,8 @@ useWorkspaceEcho<{ nonce: string }>(
|
|||
},
|
||||
);
|
||||
|
||||
const KNOWN_CONNECT_ERRORS = ['network_taken', 'wrong_chat', 'busy'];
|
||||
|
||||
useWorkspaceEcho<{ nonce: string; reason: string }>(
|
||||
'.telegram.connect.failed',
|
||||
(payload) => {
|
||||
|
|
@ -81,10 +87,11 @@ useWorkspaceEcho<{ nonce: string; reason: string }>(
|
|||
|
||||
phase.value = 'error';
|
||||
clearExpiry();
|
||||
errorMessage.value =
|
||||
payload.reason === 'network_taken'
|
||||
? trans('accounts.telegram.network_taken')
|
||||
: trans('accounts.telegram.error_generic');
|
||||
errorMessage.value = trans(
|
||||
KNOWN_CONNECT_ERRORS.includes(payload.reason)
|
||||
? `accounts.telegram.${payload.reason}`
|
||||
: 'accounts.telegram.error_generic',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
|
|
@ -93,7 +100,13 @@ const start = async () => {
|
|||
errorMessage.value = '';
|
||||
|
||||
try {
|
||||
const response = await httpConnect.post(connectTelegram.url());
|
||||
const response = await httpConnect.post(
|
||||
connectTelegram.url(
|
||||
props.reconnectId
|
||||
? { query: { reconnect: props.reconnectId } }
|
||||
: undefined,
|
||||
),
|
||||
);
|
||||
code.value = response.code;
|
||||
nonce.value = response.nonce;
|
||||
botUsername.value = response.bot_username;
|
||||
|
|
|
|||
|
|
@ -23,7 +23,6 @@ const tabs = computed(() => [
|
|||
v-for="tab in tabs"
|
||||
:key="tab.key"
|
||||
:href="tab.href"
|
||||
:dusk="`automation-tab-${tab.key}`"
|
||||
class="-mb-0.5 shrink-0 whitespace-nowrap border-b-2 py-2.5 text-sm font-medium transition-colors"
|
||||
:class="
|
||||
tab.key === current
|
||||
|
|
|
|||
|
|
@ -65,7 +65,7 @@ const openDeleteModal = () => {
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-6" dusk="workspace-danger-zone">
|
||||
<div class="space-y-6">
|
||||
<HeadingSmall
|
||||
:title="$t('settings.workspace.delete_title')"
|
||||
:description="$t('settings.workspace.danger_description')"
|
||||
|
|
@ -83,7 +83,6 @@ const openDeleteModal = () => {
|
|||
<Button
|
||||
v-if="!isOnlyWorkspace"
|
||||
variant="destructive"
|
||||
dusk="workspace-delete"
|
||||
@click="openDeleteModal"
|
||||
>
|
||||
{{ $t('settings.workspace.delete_action') }}
|
||||
|
|
@ -92,7 +91,6 @@ const openDeleteModal = () => {
|
|||
<Button
|
||||
variant="outline"
|
||||
as-child
|
||||
dusk="workspace-delete-billing-link"
|
||||
>
|
||||
<Link :href="billingIndex()">
|
||||
{{ $t('settings.workspace.delete_go_to_billing') }}
|
||||
|
|
@ -101,7 +99,6 @@ const openDeleteModal = () => {
|
|||
<Button
|
||||
variant="destructive"
|
||||
as-child
|
||||
dusk="workspace-delete-account-link"
|
||||
>
|
||||
<Link :href="editAuthentication()">
|
||||
{{ $t('settings.workspace.delete_go_to_delete_account') }}
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ import { Platform } from '@/types/platform';
|
|||
const POPUP_WIDTH = 600;
|
||||
const POPUP_HEIGHT = 700;
|
||||
|
||||
const CONNECT_ROUTES: Record<string, { url: () => string }> = {
|
||||
const CONNECT_ROUTES: Record<string, { url: (options?: { query?: Record<string, string> }) => string }> = {
|
||||
[Platform.Bluesky]: blueskyConnect,
|
||||
[Platform.Discord]: discordConnect,
|
||||
[Platform.Facebook]: facebookConnect,
|
||||
|
|
@ -40,24 +40,28 @@ export interface SocialOAuthResult {
|
|||
platform: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens a platform's OAuth connect flow in a centered popup and invokes
|
||||
* `onResult` with the popup's `{success, message}` outcome. The listener is
|
||||
* wired to the calling component's lifecycle.
|
||||
*/
|
||||
export const useOAuthPopup = (onResult: (result: SocialOAuthResult) => void) => {
|
||||
const openOAuthPopup = (platform: string) => {
|
||||
export const oauthConnectUrl = (platform: string, reconnectId?: string): string | undefined => {
|
||||
const route = CONNECT_ROUTES[platform];
|
||||
|
||||
if (!route) {
|
||||
return;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return route.url(reconnectId ? { query: { reconnect: reconnectId } } : undefined);
|
||||
};
|
||||
|
||||
/**
|
||||
* Opens a connect URL in a centered popup and invokes `onResult` with the
|
||||
* popup's `{success, message}` outcome. The listener is wired to the calling
|
||||
* component's lifecycle.
|
||||
*/
|
||||
export const useOAuthPopup = (onResult: (result: SocialOAuthResult) => void) => {
|
||||
const openOAuthPopup = (url: string) => {
|
||||
const left = window.screenX + (window.outerWidth - POPUP_WIDTH) / 2;
|
||||
const top = window.screenY + (window.outerHeight - POPUP_HEIGHT) / 2;
|
||||
|
||||
const popup = window.open(
|
||||
route.url(),
|
||||
url,
|
||||
'oauth-popup',
|
||||
`width=${POPUP_WIDTH},height=${POPUP_HEIGHT},left=${left},top=${top},scrollbars=yes,resizable=yes`,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -58,9 +58,31 @@ export interface ContentTypeOption {
|
|||
labelKey: string;
|
||||
}
|
||||
|
||||
const PLATFORM_THEMES: Record<string, { bg: string; rotate: string }> = {
|
||||
instagram: { bg: 'bg-pink-200', rotate: '-rotate-2' },
|
||||
'instagram-facebook': { bg: 'bg-pink-200', rotate: '-rotate-2' },
|
||||
facebook: { bg: 'bg-sky-200', rotate: 'rotate-1' },
|
||||
linkedin: { bg: 'bg-blue-200', rotate: '-rotate-1' },
|
||||
'linkedin-page': { bg: 'bg-blue-200', rotate: '-rotate-1' },
|
||||
x: { bg: 'bg-amber-200', rotate: 'rotate-2' },
|
||||
tiktok: { bg: 'bg-fuchsia-200', rotate: '-rotate-1' },
|
||||
youtube: { bg: 'bg-red-200', rotate: 'rotate-1' },
|
||||
pinterest: { bg: 'bg-rose-200', rotate: '-rotate-2' },
|
||||
threads: { bg: 'bg-emerald-200', rotate: 'rotate-2' },
|
||||
bluesky: { bg: 'bg-cyan-200', rotate: '-rotate-1' },
|
||||
mastodon: { bg: 'bg-violet-200', rotate: 'rotate-1' },
|
||||
telegram: { bg: 'bg-sky-200', rotate: '-rotate-2' },
|
||||
discord: { bg: 'bg-indigo-200', rotate: 'rotate-1' },
|
||||
};
|
||||
|
||||
export const getPlatformLogo = (platform: string): string =>
|
||||
PLATFORM_LOGOS[platform] ?? PLATFORM_LOGOS.linkedin;
|
||||
|
||||
export const getPlatformTheme = (platform: string): { bg: string; rotate: string; image: string } => ({
|
||||
...(PLATFORM_THEMES[platform] ?? { bg: 'bg-muted', rotate: '' }),
|
||||
image: getPlatformLogo(platform),
|
||||
});
|
||||
|
||||
export const getPlatformLabel = (platform: string): string =>
|
||||
PLATFORM_LABELS[platform] ?? platform;
|
||||
|
||||
|
|
|
|||
|
|
@ -95,7 +95,6 @@ const canNavigateTo = (stepNumber: number): boolean =>
|
|||
})
|
||||
"
|
||||
:data-testid="`welcome-step-${stepNumber}`"
|
||||
:dusk="`welcome-step-${stepNumber}`"
|
||||
>
|
||||
<span
|
||||
class="h-2 w-full rounded-full bg-primary transition-opacity hover:opacity-70 motion-reduce:transition-none"
|
||||
|
|
@ -105,7 +104,6 @@ const canNavigateTo = (stepNumber: number): boolean =>
|
|||
v-else
|
||||
class="flex h-6 w-8 items-center"
|
||||
:data-testid="`welcome-step-${stepNumber}`"
|
||||
:dusk="`welcome-step-${stepNumber}`"
|
||||
:aria-current="
|
||||
stepNumber === step ? 'step' : undefined
|
||||
"
|
||||
|
|
|
|||
|
|
@ -69,7 +69,6 @@ const pageUrl = (username: string | null): string | null =>
|
|||
v-for="page in pages"
|
||||
:key="page.id"
|
||||
class="flex items-center gap-4 rounded-lg border bg-card p-4"
|
||||
dusk="facebook-page"
|
||||
>
|
||||
<Avatar class="h-12 w-12 shrink-0 rounded-lg">
|
||||
<AvatarImage v-if="page.picture" :src="page.picture" class="object-cover" />
|
||||
|
|
@ -94,7 +93,7 @@ const pageUrl = (username: string | null): string | null =>
|
|||
<IconExternalLink class="h-4 w-4" />
|
||||
<span class="hidden sm:inline">{{ $t('accounts.facebook.view') }}</span>
|
||||
</Button>
|
||||
<Button size="sm" dusk="choose-facebook-page" :disabled="form.processing" @click="handleSelectPage(page)">
|
||||
<Button size="sm" :disabled="form.processing" @click="handleSelectPage(page)">
|
||||
{{ $t('accounts.facebook.choose') }}
|
||||
</Button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -71,7 +71,6 @@ const igUrl = (username: string): string => `https://www.instagram.com/${usernam
|
|||
v-for="page in pages"
|
||||
:key="page.ig_id"
|
||||
class="flex items-center gap-4 rounded-lg border bg-card p-4"
|
||||
dusk="instagram-page"
|
||||
>
|
||||
<Avatar class="h-12 w-12 shrink-0 rounded-lg">
|
||||
<AvatarImage v-if="page.ig_picture" :src="page.ig_picture" class="object-cover" />
|
||||
|
|
@ -92,7 +91,7 @@ const igUrl = (username: string): string => `https://www.instagram.com/${usernam
|
|||
<IconExternalLink class="h-4 w-4" />
|
||||
<span class="hidden sm:inline">{{ $t('accounts.instagram_facebook.view') }}</span>
|
||||
</Button>
|
||||
<Button size="sm" dusk="choose-instagram-page" :disabled="form.processing" @click="handleSelectPage(page)">
|
||||
<Button size="sm" :disabled="form.processing" @click="handleSelectPage(page)">
|
||||
{{ $t('accounts.instagram_facebook.choose') }}
|
||||
</Button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -77,7 +77,7 @@ const organizationUrl = (vanity: string | null): string | null =>
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="isEmpty" class="py-12 text-center" dusk="linkedin-identity-empty">
|
||||
<div v-if="isEmpty" class="py-12 text-center">
|
||||
<div class="mx-auto flex h-14 w-14 items-center justify-center rounded-full bg-muted">
|
||||
<IconBuilding class="h-7 w-7 text-muted-foreground" />
|
||||
</div>
|
||||
|
|
@ -91,7 +91,6 @@ const organizationUrl = (vanity: string | null): string | null =>
|
|||
<div
|
||||
v-if="person"
|
||||
class="flex items-center gap-4 rounded-lg border bg-card p-4"
|
||||
dusk="linkedin-identity-person"
|
||||
>
|
||||
<Avatar class="h-12 w-12 shrink-0 rounded-lg">
|
||||
<AvatarImage v-if="person.avatar" :src="person.avatar" class="object-cover" />
|
||||
|
|
@ -123,7 +122,7 @@ const organizationUrl = (vanity: string | null): string | null =>
|
|||
<IconExternalLink class="h-4 w-4" />
|
||||
<span class="hidden sm:inline">{{ $t('accounts.linkedin.view') }}</span>
|
||||
</Button>
|
||||
<Button size="sm" dusk="choose-person" :disabled="form.processing" @click="choosePerson">
|
||||
<Button size="sm" :disabled="form.processing" @click="choosePerson">
|
||||
{{ $t('accounts.linkedin.choose') }}
|
||||
</Button>
|
||||
</div>
|
||||
|
|
@ -133,7 +132,6 @@ const organizationUrl = (vanity: string | null): string | null =>
|
|||
v-for="org in organizations"
|
||||
:key="org.id"
|
||||
class="flex items-center gap-4 rounded-lg border bg-card p-4"
|
||||
dusk="linkedin-identity-organization"
|
||||
>
|
||||
<Avatar class="h-12 w-12 shrink-0 rounded-lg">
|
||||
<AvatarImage v-if="org.logo" :src="org.logo" class="object-cover" />
|
||||
|
|
@ -165,7 +163,7 @@ const organizationUrl = (vanity: string | null): string | null =>
|
|||
<IconExternalLink class="h-4 w-4" />
|
||||
<span class="hidden sm:inline">{{ $t('accounts.linkedin.view') }}</span>
|
||||
</Button>
|
||||
<Button size="sm" dusk="choose-organization" :disabled="form.processing" @click="chooseOrganization(org)">
|
||||
<Button size="sm" :disabled="form.processing" @click="chooseOrganization(org)">
|
||||
{{ $t('accounts.linkedin.choose') }}
|
||||
</Button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ onMounted(() => {
|
|||
|
||||
<template>
|
||||
<PopupLayout :title="success ? $t('accounts.popup_callback.title_success') : $t('accounts.popup_callback.title_error')">
|
||||
<div class="flex flex-col items-center justify-center gap-3 py-16 text-center" dusk="popup-callback" role="status" aria-live="polite">
|
||||
<div class="flex flex-col items-center justify-center gap-3 py-16 text-center" role="status" aria-live="polite">
|
||||
<div
|
||||
class="flex h-14 w-14 items-center justify-center rounded-full"
|
||||
:class="
|
||||
|
|
|
|||
|
|
@ -57,7 +57,6 @@ const emailFormVisible = computed(() => !hasSocial.value || showEmailForm.value)
|
|||
type="button"
|
||||
variant="outline"
|
||||
class="w-full"
|
||||
dusk="register-email-toggle"
|
||||
@click="showEmailForm = true"
|
||||
>
|
||||
<IconMail class="size-4" />
|
||||
|
|
|
|||
|
|
@ -173,7 +173,6 @@ const toggleExpand = async (invocation: Invocation) => {
|
|||
<div class="flex items-center gap-2 sm:contents">
|
||||
<Select v-model="statusFilter">
|
||||
<SelectTrigger
|
||||
dusk="invocations-status-filter"
|
||||
class="w-full sm:w-44"
|
||||
>
|
||||
<SelectValue>{{ statusLabel }}</SelectValue>
|
||||
|
|
@ -207,7 +206,6 @@ const toggleExpand = async (invocation: Invocation) => {
|
|||
$t('automations.invocations.refresh')
|
||||
"
|
||||
:disabled="isRefreshing"
|
||||
dusk="invocations-refresh"
|
||||
@click="reload"
|
||||
>
|
||||
<IconRefresh
|
||||
|
|
@ -228,7 +226,6 @@ const toggleExpand = async (invocation: Invocation) => {
|
|||
$t('automations.invocations.search_placeholder')
|
||||
"
|
||||
class="sm:max-w-xs"
|
||||
dusk="invocations-search"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -292,7 +289,6 @@ const toggleExpand = async (invocation: Invocation) => {
|
|||
>
|
||||
<TableRow
|
||||
class="cursor-pointer"
|
||||
:dusk="`invocation-row-${invocation.id}`"
|
||||
@click="toggleExpand(invocation)"
|
||||
>
|
||||
<TableCell>
|
||||
|
|
|
|||
|
|
@ -74,7 +74,7 @@ const platformLabel = (platform: string): string => platform.charAt(0).toUpperCa
|
|||
<div class="space-y-6 p-4">
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<h2 class="text-sm font-semibold text-foreground/70">{{ $t('automations.metrics.overview') }}</h2>
|
||||
<DateRangePicker v-model="dateRange" dusk="metrics-range" />
|
||||
<DateRangePicker v-model="dateRange" />
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-7">
|
||||
|
|
|
|||
|
|
@ -125,13 +125,11 @@ const openDeleteModal = () => {
|
|||
id="name"
|
||||
name="name"
|
||||
:default-value="automation.name"
|
||||
dusk="automation-name-input"
|
||||
/>
|
||||
<InputError :message="errors.name" class="mt-1" />
|
||||
</div>
|
||||
<Button
|
||||
:disabled="processing"
|
||||
dusk="automation-name-save"
|
||||
>{{ $t('automations.actions.save') }}</Button
|
||||
>
|
||||
</div>
|
||||
|
|
@ -171,7 +169,6 @@ const openDeleteModal = () => {
|
|||
? $t('automations.actions.pause')
|
||||
: $t('automations.actions.activate')
|
||||
"
|
||||
dusk="automation-toggle-active"
|
||||
@update:model-value="toggleActive"
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -179,7 +176,6 @@ const openDeleteModal = () => {
|
|||
<p
|
||||
v-if="activationBlocked"
|
||||
class="flex items-center gap-1.5 text-xs font-medium text-amber-600 dark:text-amber-500"
|
||||
dusk="automation-activation-blocked"
|
||||
>
|
||||
<IconAlertCircle class="size-4 flex-shrink-0" />
|
||||
{{ configIssue }}
|
||||
|
|
@ -207,7 +203,6 @@ const openDeleteModal = () => {
|
|||
</div>
|
||||
<Button
|
||||
variant="destructive"
|
||||
dusk="automation-delete"
|
||||
@click="openDeleteModal"
|
||||
>
|
||||
<IconTrash class="size-4" />
|
||||
|
|
|
|||
|
|
@ -125,7 +125,7 @@ const onDenySubmit = (): void => {
|
|||
<p class="text-sm text-muted-foreground">
|
||||
{{ $t('mcp.authorize.logged_in_as') }}
|
||||
</p>
|
||||
<p class="font-medium" dusk="mcp-authorize-email">
|
||||
<p class="font-medium">
|
||||
{{ user.email }}
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -145,7 +145,6 @@ const onDenySubmit = (): void => {
|
|||
<button
|
||||
type="button"
|
||||
class="flex h-10 w-full items-center justify-between rounded-md border-2 border-foreground bg-card px-3 py-2 text-sm font-medium text-foreground shadow-2xs transition-colors hover:bg-muted/40"
|
||||
dusk="mcp-authorize-workspace"
|
||||
>
|
||||
<span
|
||||
:class="
|
||||
|
|
@ -236,7 +235,6 @@ const onDenySubmit = (): void => {
|
|||
'pointer-events-none opacity-25': !canApprove,
|
||||
}"
|
||||
:loading="approving"
|
||||
dusk="mcp-authorize-approve"
|
||||
>
|
||||
{{ $t('mcp.authorize.approve') }}
|
||||
</Button>
|
||||
|
|
@ -257,7 +255,6 @@ const onDenySubmit = (): void => {
|
|||
type="submit"
|
||||
variant="outline"
|
||||
class="w-full"
|
||||
dusk="mcp-authorize-cancel"
|
||||
>
|
||||
{{ $t('mcp.authorize.cancel') }}
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -15,14 +15,12 @@ defineProps<{
|
|||
>
|
||||
<p
|
||||
class="text-sm text-muted-foreground"
|
||||
dusk="mcp-authorize-error-description"
|
||||
>
|
||||
{{ errorDescription }}
|
||||
</p>
|
||||
|
||||
<p
|
||||
class="rounded-lg border-2 border-foreground bg-muted px-3 py-2 font-mono text-xs text-foreground"
|
||||
dusk="mcp-authorize-error-code"
|
||||
>
|
||||
{{ $t('mcp.authorize.error_code', { error }) }}
|
||||
</p>
|
||||
|
|
|
|||
|
|
@ -59,8 +59,7 @@ const socialConnectedElsewhere = computed(
|
|||
// (auto-complete POST can fail before completed_at lands).
|
||||
useOnboardingLiveReload({
|
||||
only: reloadOnly,
|
||||
enabled: () =>
|
||||
!props.status.completed_at && !props.status.dismissed_at,
|
||||
enabled: () => !props.status.completed_at && !props.status.dismissed_at,
|
||||
});
|
||||
|
||||
const stampCompletionIfReady = (): void => {
|
||||
|
|
|
|||
|
|
@ -49,19 +49,16 @@ const submit = (): void => {
|
|||
:connected-accounts="accounts"
|
||||
grid-class="grid-cols-2 sm:grid-cols-3 xl:grid-cols-6"
|
||||
data-testid="welcome-connect-grid"
|
||||
dusk="welcome-connect-grid"
|
||||
/>
|
||||
|
||||
<div class="mx-auto flex w-full max-w-sm flex-col items-center gap-3">
|
||||
<InputError
|
||||
:message="form.errors.connect"
|
||||
dusk="welcome-connect-error"
|
||||
/>
|
||||
<Button as-child size="lg" class="w-full rounded-full">
|
||||
<button
|
||||
type="button"
|
||||
data-testid="welcome-start-checkout"
|
||||
dusk="welcome-start-checkout"
|
||||
:disabled="form.processing || !hasConnectedAccount"
|
||||
@click="submit"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -222,7 +222,6 @@ const submit = (): void => {
|
|||
class="w-full rounded-full"
|
||||
:disabled="form.referral_source === '' || form.processing"
|
||||
data-testid="welcome-referral-continue"
|
||||
dusk="welcome-referral-continue"
|
||||
@click="submit"
|
||||
>
|
||||
{{ $t('welcome.continue') }}
|
||||
|
|
|
|||
1
resources/js/types/index.d.ts
vendored
1
resources/js/types/index.d.ts
vendored
|
|
@ -92,6 +92,7 @@ export interface SharedData {
|
|||
onboardingProgress?: OnboardingProgress | false;
|
||||
sidebarOpen: boolean;
|
||||
selfHosted: boolean;
|
||||
allowMultipleSocialAccounts: boolean;
|
||||
contentTypeMediaRules?: Record<string, ContentTypeMediaRule>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -127,8 +127,6 @@
|
|||
Route::get('accounts/tiktok/callback', [TikTokController::class, 'callback'])->name('app.social.tiktok.callback');
|
||||
|
||||
Route::get('accounts/youtube/callback', [YouTubeController::class, 'callback'])->name('app.social.youtube.callback');
|
||||
Route::get('accounts/youtube/select', [YouTubeController::class, 'selectChannel'])->name('app.social.youtube.select-channel');
|
||||
Route::post('accounts/youtube/select', [YouTubeController::class, 'select'])->name('app.social.youtube.select');
|
||||
|
||||
Route::get('accounts/facebook/callback', [FacebookController::class, 'callback'])->name('app.social.facebook.callback');
|
||||
Route::get('accounts/facebook/select', [FacebookController::class, 'selectPage'])->name('app.social.facebook.select-page');
|
||||
|
|
|
|||
49
tests/Browser/InstagramConnectDialogTest.php
Normal file
49
tests/Browser/InstagramConnectDialogTest.php
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Enums\UserWorkspace\Role;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
|
||||
/**
|
||||
* The picker emits an already-resolved connect method, so it must bypass the
|
||||
* picker branch. Wiring it back through startConnect() reopened the dialog and
|
||||
* made the standalone Instagram flow unreachable.
|
||||
*/
|
||||
function instagramConnectOwner(): User
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$workspace = Workspace::factory()->create([
|
||||
'account_id' => $user->account_id,
|
||||
'user_id' => $user->id,
|
||||
]);
|
||||
$workspace->members()->attach($user->id, ['role' => Role::Admin->value]);
|
||||
$user->update(['current_workspace_id' => $workspace->id]);
|
||||
|
||||
return $user->fresh();
|
||||
}
|
||||
|
||||
test('choosing standalone instagram closes the picker instead of reopening it', function () {
|
||||
$this->actingAs(instagramConnectOwner());
|
||||
|
||||
$page = visit(route('app.accounts'));
|
||||
|
||||
$page->click('@connect-instagram')
|
||||
->assertVisible('@instagram-connect-dialog')
|
||||
->click('@instagram-connect-standalone')
|
||||
->assertMissing('@instagram-connect-dialog')
|
||||
->assertNoJavaScriptErrors();
|
||||
});
|
||||
|
||||
test('choosing instagram via facebook also closes the picker', function () {
|
||||
$this->actingAs(instagramConnectOwner());
|
||||
|
||||
$page = visit(route('app.accounts'));
|
||||
|
||||
$page->click('@connect-instagram')
|
||||
->assertVisible('@instagram-connect-dialog')
|
||||
->click('@instagram-connect-facebook')
|
||||
->assertMissing('@instagram-connect-dialog')
|
||||
->assertNoJavaScriptErrors();
|
||||
});
|
||||
73
tests/Browser/NetworkConnectGridTest.php
Normal file
73
tests/Browser/NetworkConnectGridTest.php
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Enums\UserWorkspace\Role;
|
||||
use App\Models\SocialAccount;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
|
||||
/**
|
||||
* Wait for a data-testid element to mount and lay out. Pest browser `@`
|
||||
* selectors resolve to data-testid, and assertions do not auto-wait on SPA paint.
|
||||
*/
|
||||
function waitForGridTestId(mixed $page, string $testId): void
|
||||
{
|
||||
$page->script(<<<JS
|
||||
(async () => {
|
||||
const sel = '[data-testid="{$testId}"]';
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const el = document.querySelector(sel);
|
||||
if (el && el.getBoundingClientRect().height > 0) return;
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
}
|
||||
})();
|
||||
JS);
|
||||
}
|
||||
|
||||
function gridOwnerWithLinkedIn(): User
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$workspace = Workspace::factory()->create([
|
||||
'account_id' => $user->account_id,
|
||||
'user_id' => $user->id,
|
||||
]);
|
||||
$workspace->members()->attach($user->id, ['role' => Role::Admin->value]);
|
||||
$user->update(['current_workspace_id' => $workspace->id]);
|
||||
|
||||
SocialAccount::factory()->linkedin()->create([
|
||||
'workspace_id' => $workspace->id,
|
||||
'platform_user_id' => 'li-connected',
|
||||
]);
|
||||
|
||||
return $user->fresh();
|
||||
}
|
||||
|
||||
test('a taken network offers no second card when multiples are disabled', function () {
|
||||
config(['trypost.allow_multiple_social_accounts' => false]);
|
||||
|
||||
$this->actingAs(gridOwnerWithLinkedIn());
|
||||
|
||||
$page = visit(route('app.accounts'));
|
||||
|
||||
waitForGridTestId($page, 'connect-x');
|
||||
|
||||
$page->assertVisible('@connect-x')
|
||||
->assertMissing('@connect-linkedin')
|
||||
->assertNoJavaScriptErrors();
|
||||
});
|
||||
|
||||
test('a taken network offers another card when multiples are allowed', function () {
|
||||
config(['trypost.allow_multiple_social_accounts' => true]);
|
||||
|
||||
$this->actingAs(gridOwnerWithLinkedIn());
|
||||
|
||||
$page = visit(route('app.accounts'));
|
||||
|
||||
waitForGridTestId($page, 'connect-linkedin');
|
||||
|
||||
$page->assertVisible('@connect-linkedin')
|
||||
->assertVisible('@connect-x')
|
||||
->assertNoJavaScriptErrors();
|
||||
});
|
||||
|
|
@ -125,6 +125,7 @@
|
|||
});
|
||||
|
||||
it('rejects creating a post with an inactive social account', function () {
|
||||
config()->set('trypost.allow_multiple_social_accounts', true);
|
||||
$inactive = SocialAccount::factory()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'platform' => Platform::LinkedIn,
|
||||
|
|
|
|||
|
|
@ -35,6 +35,18 @@
|
|||
expect($page['props']['selfHosted'])->toBeTrue();
|
||||
});
|
||||
|
||||
test('login page exposes allowMultipleSocialAccounts independently of selfHosted', function () {
|
||||
config()->set('trypost.self_hosted', false);
|
||||
config()->set('trypost.allow_multiple_social_accounts', true);
|
||||
|
||||
$response = $this->get(route('login'));
|
||||
|
||||
$response->assertOk();
|
||||
$page = $response->original->getData()['page'];
|
||||
expect($page['props']['selfHosted'])->toBeFalse()
|
||||
->and($page['props']['allowMultipleSocialAccounts'])->toBeTrue();
|
||||
});
|
||||
|
||||
test('users can authenticate using the login screen', function () {
|
||||
$user = User::factory()->create();
|
||||
|
||||
|
|
|
|||
|
|
@ -118,6 +118,7 @@
|
|||
});
|
||||
|
||||
it('returns only active social accounts for the automation workspace', function () {
|
||||
config()->set('trypost.allow_multiple_social_accounts', true);
|
||||
$this->mock(PinterestPublisher::class);
|
||||
$this->mock(TikTokCreatorInfo::class);
|
||||
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@
|
|||
use Illuminate\Support\Facades\Queue;
|
||||
|
||||
test('it dispatches refresh jobs for rotating tokens near expiry and extension tokens well ahead of expiry', function () {
|
||||
config()->set('trypost.allow_multiple_social_accounts', true);
|
||||
Queue::fake();
|
||||
|
||||
$workspace = Workspace::factory()->create();
|
||||
|
|
|
|||
|
|
@ -128,6 +128,7 @@
|
|||
});
|
||||
|
||||
test('instagram standalone publisher uses graph.instagram.com', function () {
|
||||
config()->set('trypost.allow_multiple_social_accounts', true);
|
||||
$standaloneAccount = SocialAccount::factory()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'platform' => Platform::Instagram,
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@
|
|||
'account_id' => $this->account->id,
|
||||
'user_id' => $this->user->id,
|
||||
]);
|
||||
config()->set('trypost.allow_multiple_social_accounts', true);
|
||||
SocialAccount::factory()->count(2)->create(['workspace_id' => $workspace->id]);
|
||||
Post::factory()->count(3)->create([
|
||||
'workspace_id' => $workspace->id,
|
||||
|
|
|
|||
|
|
@ -1001,6 +1001,7 @@
|
|||
});
|
||||
|
||||
test('still verifies and warns about an active account when another account in the same workspace is paused', function () {
|
||||
config()->set('trypost.allow_multiple_social_accounts', true);
|
||||
Mail::fake();
|
||||
|
||||
$workspace = Workspace::factory()->create();
|
||||
|
|
|
|||
|
|
@ -177,6 +177,7 @@
|
|||
});
|
||||
|
||||
test('create post rejects an inactive social account', function () {
|
||||
config()->set('trypost.allow_multiple_social_accounts', true);
|
||||
$inactive = SocialAccount::factory()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'platform' => Platform::LinkedIn,
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@
|
|||
'user_id' => $this->owner->id,
|
||||
]);
|
||||
$workspace = $this->account->workspaces()->first();
|
||||
config()->set('trypost.allow_multiple_social_accounts', true);
|
||||
SocialAccount::factory()->count(3)->create(['workspace_id' => $workspace->id]);
|
||||
|
||||
User::factory()->count(2)->create(['account_id' => $this->account->id]);
|
||||
|
|
|
|||
|
|
@ -126,6 +126,8 @@
|
|||
});
|
||||
|
||||
test('updating status on multiple batch-hydrated social accounts does not throw a lazy loading violation', function () {
|
||||
config()->set('trypost.allow_multiple_social_accounts', true);
|
||||
|
||||
$accounts = SocialAccount::factory()->count(2)->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'status' => Status::Connected,
|
||||
|
|
|
|||
|
|
@ -1280,6 +1280,7 @@ function fakeJpegBytes(int $width = 1200, int $height = 800): string
|
|||
});
|
||||
|
||||
test('instagram facebook publisher recovers a published container on graph.facebook.com', function () {
|
||||
config()->set('trypost.allow_multiple_social_accounts', true);
|
||||
$account = SocialAccount::factory()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'platform' => Platform::InstagramFacebook,
|
||||
|
|
|
|||
|
|
@ -80,8 +80,8 @@
|
|||
]);
|
||||
});
|
||||
|
||||
test('user can connect multiple bluesky accounts in self-hosted mode', function () {
|
||||
config()->set('trypost.self_hosted', true);
|
||||
test('user can connect multiple bluesky accounts when multiple social accounts are allowed', function () {
|
||||
config()->set('trypost.allow_multiple_social_accounts', true);
|
||||
|
||||
SocialAccount::factory()->bluesky()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
|
|
@ -113,6 +113,40 @@
|
|||
expect($this->workspace->socialAccounts()->where('platform', Platform::Bluesky)->count())->toBe(2);
|
||||
});
|
||||
|
||||
test('bluesky store shows network_taken when the network is already connected', function () {
|
||||
config()->set('trypost.allow_multiple_social_accounts', false);
|
||||
|
||||
SocialAccount::factory()->bluesky()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'platform_user_id' => 'did:plc:existing123',
|
||||
]);
|
||||
|
||||
Http::fake([
|
||||
'https://bsky.social/xrpc/com.atproto.server.createSession' => Http::response([
|
||||
'did' => 'did:plc:newuser456',
|
||||
'handle' => 'newuser.bsky.social',
|
||||
'accessJwt' => 'test-access-token',
|
||||
'refreshJwt' => 'test-refresh-token',
|
||||
], 200),
|
||||
'https://bsky.social/xrpc/app.bsky.actor.getProfile*' => Http::response([
|
||||
'did' => 'did:plc:newuser456',
|
||||
'handle' => 'newuser.bsky.social',
|
||||
'displayName' => 'New User',
|
||||
], 200),
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($this->user)->post(route('app.social.bluesky.store'), [
|
||||
'identifier' => 'newuser.bsky.social',
|
||||
'password' => 'xxxx-xxxx-xxxx-xxxx',
|
||||
]);
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertInertia(fn (AssertableInertia $page) => $page->where('success', false));
|
||||
$response->assertInertia(fn (AssertableInertia $page) => $page->where('message', __('accounts.popup_callback.network_taken')));
|
||||
|
||||
expect($this->workspace->socialAccounts()->where('platform', Platform::Bluesky)->count())->toBe(1);
|
||||
});
|
||||
|
||||
test('bluesky connection validates required fields', function () {
|
||||
$response = $this->actingAs($this->user)->post(route('app.social.bluesky.store'), [
|
||||
'identifier' => '',
|
||||
|
|
@ -121,3 +155,90 @@
|
|||
|
||||
$response->assertSessionHasErrors(['identifier', 'password']);
|
||||
});
|
||||
|
||||
test('bluesky store reconnects the original card', function () {
|
||||
$account = SocialAccount::factory()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'platform' => Platform::Bluesky,
|
||||
'platform_user_id' => 'did:plc:testuser123',
|
||||
'username' => 'old',
|
||||
'access_token' => 'expired-token',
|
||||
'status' => Status::TokenExpired,
|
||||
]);
|
||||
|
||||
session(['social_reconnect_id' => $account->id]);
|
||||
|
||||
$service = config('trypost.platforms.bluesky.default_service');
|
||||
|
||||
Http::fake([
|
||||
"{$service}/xrpc/com.atproto.server.createSession" => Http::response([
|
||||
'did' => 'did:plc:testuser123',
|
||||
'handle' => 'testuser.bsky.social',
|
||||
'accessJwt' => 'fresh-access-token',
|
||||
'refreshJwt' => 'fresh-refresh-token',
|
||||
], 200),
|
||||
"{$service}/xrpc/app.bsky.actor.getProfile*" => Http::response([
|
||||
'did' => 'did:plc:testuser123',
|
||||
'handle' => 'testuser.bsky.social',
|
||||
'displayName' => 'Test User',
|
||||
'avatar' => null,
|
||||
], 200),
|
||||
]);
|
||||
|
||||
$this->actingAs($this->user)
|
||||
->post(route('app.social.bluesky.store'), [
|
||||
'identifier' => 'testuser.bsky.social',
|
||||
'password' => 'xxxx-xxxx-xxxx-xxxx',
|
||||
])
|
||||
->assertOk()
|
||||
->assertInertia(fn (AssertableInertia $page) => $page
|
||||
->where('success', true)
|
||||
->where('message', __('accounts.popup_callback.reconnected'))
|
||||
);
|
||||
|
||||
expect($this->workspace->socialAccounts()->count())->toBe(1)
|
||||
->and($account->fresh()->username)->toBe('testuser.bsky.social')
|
||||
->and($account->fresh()->status)->toBe(Status::Connected);
|
||||
});
|
||||
|
||||
test('bluesky reconnect that authenticates another handle says so instead of connecting', function () {
|
||||
$account = SocialAccount::factory()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'platform' => Platform::Bluesky,
|
||||
'platform_user_id' => 'did:plc:testuser123',
|
||||
'username' => 'old',
|
||||
]);
|
||||
|
||||
session(['social_reconnect_id' => $account->id]);
|
||||
|
||||
$service = config('trypost.platforms.bluesky.default_service');
|
||||
|
||||
Http::fake([
|
||||
"{$service}/xrpc/com.atproto.server.createSession" => Http::response([
|
||||
'did' => 'did:plc:someoneelse999',
|
||||
'handle' => 'someone-else.bsky.social',
|
||||
'accessJwt' => 'other-access-token',
|
||||
'refreshJwt' => 'other-refresh-token',
|
||||
], 200),
|
||||
"{$service}/xrpc/app.bsky.actor.getProfile*" => Http::response([
|
||||
'did' => 'did:plc:someoneelse999',
|
||||
'handle' => 'someone-else.bsky.social',
|
||||
'displayName' => 'Someone Else',
|
||||
'avatar' => null,
|
||||
], 200),
|
||||
]);
|
||||
|
||||
$this->actingAs($this->user)
|
||||
->post(route('app.social.bluesky.store'), [
|
||||
'identifier' => 'someone-else.bsky.social',
|
||||
'password' => 'xxxx-xxxx-xxxx-xxxx',
|
||||
])
|
||||
->assertOk()
|
||||
->assertInertia(fn (AssertableInertia $page) => $page
|
||||
->where('success', false)
|
||||
->where('message', __('accounts.popup_callback.wrong_account'))
|
||||
);
|
||||
|
||||
expect($this->workspace->socialAccounts()->count())->toBe(1)
|
||||
->and($account->fresh()->platform_user_id)->toBe('did:plc:testuser123');
|
||||
});
|
||||
|
|
|
|||
61
tests/Feature/Social/ConnectPopupExceptionTest.php
Normal file
61
tests/Feature/Social/ConnectPopupExceptionTest.php
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Enums\UserWorkspace\Role;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Inertia\Testing\AssertableInertia;
|
||||
|
||||
beforeEach(function () {
|
||||
$this->user = User::factory()->create();
|
||||
$this->workspace = Workspace::factory()->create(['user_id' => $this->user->id]);
|
||||
$this->user->update(['current_workspace_id' => $this->workspace->id]);
|
||||
$this->workspace->members()->attach($this->user->id, ['role' => Role::Member->value]);
|
||||
});
|
||||
|
||||
test('an expired connect popup closes without filing an error', function () {
|
||||
Log::spy();
|
||||
|
||||
$this->actingAs($this->user)
|
||||
->get(route('app.social.instagram.callback'))
|
||||
->assertOk()
|
||||
->assertInertia(fn (AssertableInertia $page) => $page
|
||||
->component('accounts/PopupCallback')
|
||||
->where('success', false)
|
||||
->where('message', __('accounts.popup_callback.session_expired'))
|
||||
);
|
||||
|
||||
Log::shouldNotHaveReceived('error');
|
||||
});
|
||||
|
||||
test('a lost mastodon session leaves no client credentials behind', function () {
|
||||
session([
|
||||
'mastodon_instance' => 'https://mastodon.social',
|
||||
'mastodon_client_id' => 'client-id',
|
||||
'mastodon_client_secret' => 'client-secret',
|
||||
'mastodon_oauth_state' => 'test-state',
|
||||
]);
|
||||
|
||||
$this->actingAs($this->user)
|
||||
->get(route('app.social.mastodon.callback', ['code' => 'x', 'state' => 'test-state']))
|
||||
->assertOk()
|
||||
->assertInertia(fn (AssertableInertia $page) => $page->where('success', false));
|
||||
|
||||
expect(session()->all())
|
||||
->not->toHaveKey('mastodon_client_secret')
|
||||
->not->toHaveKey('mastodon_instance')
|
||||
->not->toHaveKey('mastodon_oauth_state');
|
||||
});
|
||||
|
||||
test('a lost threads session leaves no oauth state behind', function () {
|
||||
session(['threads_oauth_state' => 'test-state']);
|
||||
|
||||
$this->actingAs($this->user)
|
||||
->get(route('app.social.threads.callback', ['code' => 'x', 'state' => 'test-state']))
|
||||
->assertOk()
|
||||
->assertInertia(fn (AssertableInertia $page) => $page->where('success', false));
|
||||
|
||||
expect(session()->all())->not->toHaveKey('threads_oauth_state');
|
||||
});
|
||||
|
|
@ -5,6 +5,7 @@
|
|||
use App\Enums\SocialAccount\Platform;
|
||||
use App\Enums\SocialAccount\Status;
|
||||
use App\Enums\UserWorkspace\Role;
|
||||
use App\Models\SocialAccount;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use Inertia\Testing\AssertableInertia;
|
||||
|
|
@ -79,3 +80,101 @@
|
|||
|
||||
expect($this->workspace->socialAccounts()->where('platform', Platform::Discord)->count())->toBe(0);
|
||||
});
|
||||
|
||||
test('user can connect multiple discord accounts when multiple social accounts are allowed', function () {
|
||||
config()->set('trypost.allow_multiple_social_accounts', true);
|
||||
|
||||
SocialAccount::factory()->discord()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'platform_user_id' => '999000111',
|
||||
]);
|
||||
|
||||
session(['social_connect_workspace' => $this->workspace->id]);
|
||||
|
||||
$socialiteUser = Mockery::mock(SocialiteUser::class);
|
||||
$socialiteUser->shouldReceive('getId')->andReturn('888000222');
|
||||
$socialiteUser->shouldReceive('getNickname')->andReturn('Another Server');
|
||||
$socialiteUser->shouldReceive('getName')->andReturn('Another Server');
|
||||
$socialiteUser->shouldReceive('getAvatar')->andReturn(null);
|
||||
$socialiteUser->token = 'discord-access-token';
|
||||
$socialiteUser->refreshToken = 'discord-refresh-token';
|
||||
$socialiteUser->expiresIn = null;
|
||||
$socialiteUser->approvedScopes = ['bot', 'identify', 'guilds'];
|
||||
|
||||
Socialite::shouldReceive('driver')->with('discord')->andReturn(Mockery::mock(['user' => $socialiteUser]));
|
||||
|
||||
$response = $this->actingAs($this->user)->get(route('app.social.discord.callback'));
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertInertia(fn (AssertableInertia $page) => $page->where('success', true));
|
||||
|
||||
expect($this->workspace->socialAccounts()->where('platform', Platform::Discord)->count())->toBe(2);
|
||||
});
|
||||
|
||||
test('discord callback shows network_taken when the network is already connected', function () {
|
||||
config()->set('trypost.allow_multiple_social_accounts', false);
|
||||
|
||||
SocialAccount::factory()->discord()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'platform_user_id' => '999000111',
|
||||
]);
|
||||
|
||||
session(['social_connect_workspace' => $this->workspace->id]);
|
||||
|
||||
$socialiteUser = Mockery::mock(SocialiteUser::class);
|
||||
$socialiteUser->shouldReceive('getId')->andReturn('888000222');
|
||||
$socialiteUser->shouldReceive('getNickname')->andReturn('Another Server');
|
||||
$socialiteUser->shouldReceive('getName')->andReturn('Another Server');
|
||||
$socialiteUser->shouldReceive('getAvatar')->andReturn(null);
|
||||
$socialiteUser->token = 'discord-access-token';
|
||||
$socialiteUser->refreshToken = 'discord-refresh-token';
|
||||
$socialiteUser->expiresIn = null;
|
||||
$socialiteUser->approvedScopes = ['bot', 'identify', 'guilds'];
|
||||
|
||||
Socialite::shouldReceive('driver')->with('discord')->andReturn(Mockery::mock(['user' => $socialiteUser]));
|
||||
|
||||
$response = $this->actingAs($this->user)->get(route('app.social.discord.callback'));
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertInertia(fn (AssertableInertia $page) => $page->where('success', false));
|
||||
$response->assertInertia(fn (AssertableInertia $page) => $page->where('message', __('accounts.popup_callback.network_taken')));
|
||||
|
||||
expect($this->workspace->socialAccounts()->where('platform', Platform::Discord)->count())->toBe(1);
|
||||
});
|
||||
|
||||
test('discord callback reconnects the original card', function () {
|
||||
$account = SocialAccount::factory()->discord()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'platform_user_id' => '999000111',
|
||||
'username' => 'old-server',
|
||||
'access_token' => 'expired-token',
|
||||
]);
|
||||
|
||||
session([
|
||||
'social_connect_workspace' => $this->workspace->id,
|
||||
'social_reconnect_id' => $account->id,
|
||||
]);
|
||||
|
||||
$socialiteUser = Mockery::mock(SocialiteUser::class);
|
||||
$socialiteUser->shouldReceive('getId')->andReturn('999000111');
|
||||
$socialiteUser->shouldReceive('getNickname')->andReturn('My Server');
|
||||
$socialiteUser->shouldReceive('getName')->andReturn('My Server');
|
||||
$socialiteUser->shouldReceive('getAvatar')->andReturn(null);
|
||||
$socialiteUser->token = 'fresh-discord-token';
|
||||
$socialiteUser->refreshToken = 'fresh-refresh-token';
|
||||
$socialiteUser->expiresIn = null;
|
||||
$socialiteUser->approvedScopes = ['bot', 'identify', 'guilds'];
|
||||
|
||||
Socialite::shouldReceive('driver')->with('discord')->andReturn(Mockery::mock(['user' => $socialiteUser]));
|
||||
|
||||
$this->actingAs($this->user)
|
||||
->get(route('app.social.discord.callback'))
|
||||
->assertOk()
|
||||
->assertInertia(fn (AssertableInertia $page) => $page
|
||||
->where('success', true)
|
||||
->where('message', __('accounts.popup_callback.reconnected'))
|
||||
);
|
||||
|
||||
expect($this->workspace->socialAccounts()->where('platform', Platform::Discord)->count())->toBe(1)
|
||||
->and($account->fresh()->access_token)->toBe('fresh-discord-token');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -86,7 +86,7 @@
|
|||
});
|
||||
|
||||
test('facebook callback shows network_taken when the network is already connected', function () {
|
||||
config()->set('trypost.self_hosted', false);
|
||||
config()->set('trypost.allow_multiple_social_accounts', false);
|
||||
|
||||
SocialAccount::factory()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
|
|
@ -388,8 +388,8 @@
|
|||
$response->assertInertia(fn (AssertableInertia $page) => $page->where('message', 'Session expired. Please try again.'));
|
||||
});
|
||||
|
||||
test('user can connect multiple facebook accounts in self-hosted mode', function () {
|
||||
config(['trypost.self_hosted' => true]);
|
||||
test('user can connect multiple facebook accounts when multiple social accounts are allowed', function () {
|
||||
config(['trypost.allow_multiple_social_accounts' => true]);
|
||||
|
||||
SocialAccount::factory()->facebook()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
|
|
@ -600,3 +600,285 @@
|
|||
$response->assertInertia(fn (AssertableInertia $page) => $page->where('success', false));
|
||||
$response->assertInertia(fn (AssertableInertia $page) => $page->where('message', 'Page not found.'));
|
||||
});
|
||||
|
||||
test('facebook connect remembers the reconnect account from the query string', function () {
|
||||
$account = SocialAccount::factory()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'platform' => Platform::Facebook,
|
||||
'platform_user_id' => 'page_1',
|
||||
]);
|
||||
|
||||
$driverMock = Mockery::mock();
|
||||
$driverMock->shouldReceive('usingGraphVersion')->andReturnSelf();
|
||||
$driverMock->shouldReceive('setScopes')->andReturnSelf();
|
||||
$driverMock->shouldReceive('redirect')->andReturn(Mockery::mock([
|
||||
'getTargetUrl' => 'https://www.facebook.com/v25.0/dialog/oauth?test=1',
|
||||
]));
|
||||
|
||||
Socialite::shouldReceive('driver')
|
||||
->with('facebook')
|
||||
->andReturn($driverMock);
|
||||
|
||||
$response = $this->actingAs($this->user)
|
||||
->withHeader('X-Inertia', 'true')
|
||||
->get(route('app.social.facebook.connect', ['reconnect' => $account->id]));
|
||||
|
||||
$response->assertStatus(409);
|
||||
|
||||
expect(session('social_connect_workspace'))->toBe($this->workspace->id)
|
||||
->and(session('social_reconnect_id'))->toBe($account->id);
|
||||
});
|
||||
|
||||
test('facebook connect ignores a reconnect id from another workspace', function () {
|
||||
$foreign = SocialAccount::factory()->create([
|
||||
'platform' => Platform::Facebook,
|
||||
'platform_user_id' => 'foreign-page',
|
||||
]);
|
||||
|
||||
$driverMock = Mockery::mock();
|
||||
$driverMock->shouldReceive('usingGraphVersion')->andReturnSelf();
|
||||
$driverMock->shouldReceive('setScopes')->andReturnSelf();
|
||||
$driverMock->shouldReceive('redirect')->andReturn(Mockery::mock([
|
||||
'getTargetUrl' => 'https://www.facebook.com/v25.0/dialog/oauth?test=1',
|
||||
]));
|
||||
|
||||
Socialite::shouldReceive('driver')
|
||||
->with('facebook')
|
||||
->andReturn($driverMock);
|
||||
|
||||
$this->actingAs($this->user)
|
||||
->withHeader('X-Inertia', 'true')
|
||||
->get(route('app.social.facebook.connect', ['reconnect' => $foreign->id]))
|
||||
->assertStatus(409);
|
||||
|
||||
expect(session('social_reconnect_id'))->toBeNull();
|
||||
});
|
||||
|
||||
test('facebook connect ignores a reconnect id from another network', function () {
|
||||
$linkedin = SocialAccount::factory()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'platform' => Platform::LinkedIn,
|
||||
'platform_user_id' => 'linkedin-member',
|
||||
]);
|
||||
|
||||
$driverMock = Mockery::mock();
|
||||
$driverMock->shouldReceive('usingGraphVersion')->andReturnSelf();
|
||||
$driverMock->shouldReceive('setScopes')->andReturnSelf();
|
||||
$driverMock->shouldReceive('redirect')->andReturn(Mockery::mock([
|
||||
'getTargetUrl' => 'https://www.facebook.com/v25.0/dialog/oauth?test=1',
|
||||
]));
|
||||
|
||||
Socialite::shouldReceive('driver')
|
||||
->with('facebook')
|
||||
->andReturn($driverMock);
|
||||
|
||||
$this->actingAs($this->user)
|
||||
->withHeader('X-Inertia', 'true')
|
||||
->get(route('app.social.facebook.connect', ['reconnect' => $linkedin->id]))
|
||||
->assertStatus(409);
|
||||
|
||||
expect(session('social_reconnect_id'))->toBeNull();
|
||||
});
|
||||
|
||||
test('facebook reconnect keeps the original card when multiple pages are returned', function () {
|
||||
$account = SocialAccount::factory()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'platform' => Platform::Facebook,
|
||||
'platform_user_id' => 'page_1',
|
||||
'username' => 'oldpage',
|
||||
'access_token' => 'expired-token',
|
||||
]);
|
||||
|
||||
session([
|
||||
'social_connect_workspace' => $this->workspace->id,
|
||||
'social_reconnect_id' => $account->id,
|
||||
]);
|
||||
|
||||
$socialiteUser = Mockery::mock(SocialiteUser::class);
|
||||
$socialiteUser->shouldReceive('getId')->andReturn('facebook_user_123');
|
||||
$socialiteUser->token = 'test-user-token';
|
||||
|
||||
Socialite::shouldReceive('driver')
|
||||
->with('facebook')
|
||||
->andReturn(Mockery::mock()->shouldReceive('usingGraphVersion')->andReturnSelf()->shouldReceive('user')->andReturn($socialiteUser)->getMock());
|
||||
|
||||
Http::fake([
|
||||
'https://graph.facebook.com/*/me/accounts*' => Http::response([
|
||||
'data' => [
|
||||
[
|
||||
'id' => 'page_1',
|
||||
'name' => 'Page 1',
|
||||
'username' => 'page1',
|
||||
'picture' => ['data' => ['url' => null]],
|
||||
'access_token' => 'fresh-token',
|
||||
],
|
||||
[
|
||||
'id' => 'page_2',
|
||||
'name' => 'Page 2',
|
||||
'username' => 'page2',
|
||||
'picture' => ['data' => ['url' => null]],
|
||||
'access_token' => 'other-token',
|
||||
],
|
||||
],
|
||||
], 200),
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($this->user)->get(route('app.social.facebook.callback'));
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertInertia(fn (AssertableInertia $page) => $page
|
||||
->component('accounts/PopupCallback')
|
||||
->where('success', true)
|
||||
->where('message', __('accounts.popup_callback.reconnected'))
|
||||
);
|
||||
|
||||
expect($this->workspace->socialAccounts()->where('platform', Platform::Facebook)->count())->toBe(1);
|
||||
|
||||
$account->refresh();
|
||||
|
||||
expect($account->platform_user_id)->toBe('page_1')
|
||||
->and($account->access_token)->toBe('fresh-token')
|
||||
->and($account->username)->toBe('page1');
|
||||
});
|
||||
|
||||
test('facebook reconnect shows page_not_found when the page is missing from graph', function () {
|
||||
$account = SocialAccount::factory()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'platform' => Platform::Facebook,
|
||||
'platform_user_id' => 'page_missing',
|
||||
]);
|
||||
|
||||
session([
|
||||
'social_connect_workspace' => $this->workspace->id,
|
||||
'social_reconnect_id' => $account->id,
|
||||
]);
|
||||
|
||||
$socialiteUser = Mockery::mock(SocialiteUser::class);
|
||||
$socialiteUser->shouldReceive('getId')->andReturn('facebook_user_123');
|
||||
$socialiteUser->token = 'test-user-token';
|
||||
|
||||
Socialite::shouldReceive('driver')
|
||||
->with('facebook')
|
||||
->andReturn(Mockery::mock()->shouldReceive('usingGraphVersion')->andReturnSelf()->shouldReceive('user')->andReturn($socialiteUser)->getMock());
|
||||
|
||||
Http::fake([
|
||||
'https://graph.facebook.com/*/me/accounts*' => Http::response([
|
||||
'data' => [
|
||||
[
|
||||
'id' => 'page_other',
|
||||
'name' => 'Other Page',
|
||||
'username' => 'other',
|
||||
'picture' => ['data' => ['url' => null]],
|
||||
'access_token' => 'other-token',
|
||||
],
|
||||
],
|
||||
], 200),
|
||||
]);
|
||||
|
||||
$this->actingAs($this->user)
|
||||
->get(route('app.social.facebook.callback'))
|
||||
->assertOk()
|
||||
->assertInertia(fn (AssertableInertia $page) => $page
|
||||
->where('success', false)
|
||||
->where('message', __('accounts.popup_callback.page_not_found'))
|
||||
);
|
||||
});
|
||||
|
||||
test('facebook select ignores a stored reconnect id from another network', function () {
|
||||
$linkedin = SocialAccount::factory()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'platform' => Platform::LinkedIn,
|
||||
'platform_user_id' => 'linkedin-member',
|
||||
]);
|
||||
|
||||
session([
|
||||
'social_connect_workspace' => $this->workspace->id,
|
||||
'facebook_oauth' => [
|
||||
'user_token' => 'test-user-token',
|
||||
'user_id' => 'facebook_user_123',
|
||||
'reconnect_id' => $linkedin->id,
|
||||
'pages' => [
|
||||
[
|
||||
'id' => 'page_123',
|
||||
'name' => 'My Facebook Page',
|
||||
'username' => 'mypage',
|
||||
'picture' => null,
|
||||
'access_token' => 'page-access-token',
|
||||
],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$this->actingAs($this->user)
|
||||
->post(route('app.social.facebook.select'), ['page_id' => 'page_123'])
|
||||
->assertOk()
|
||||
->assertInertia(fn (AssertableInertia $page) => $page->where('success', true));
|
||||
|
||||
expect($linkedin->fresh()->platform)->toBe(Platform::LinkedIn)
|
||||
->and($this->workspace->socialAccounts()->where('platform', Platform::Facebook)->count())->toBe(1);
|
||||
});
|
||||
|
||||
test('facebook page picker refuses a user who can no longer manage accounts', function () {
|
||||
$outsider = User::factory()->create();
|
||||
|
||||
session([
|
||||
'social_connect_workspace' => $this->workspace->id,
|
||||
'facebook_oauth' => [
|
||||
'user_token' => 'user-token',
|
||||
'user_id' => 'fb-user',
|
||||
'pages' => [
|
||||
['id' => 'page-1', 'name' => 'My Page', 'access_token' => 'page-token'],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$this->actingAs($outsider)
|
||||
->get(route('app.social.facebook.select-page'))
|
||||
->assertOk()
|
||||
->assertInertia(fn (AssertableInertia $page) => $page
|
||||
->component('accounts/PopupCallback')
|
||||
->where('success', false)
|
||||
->where('message', __('accounts.popup_callback.workspace_not_found'))
|
||||
);
|
||||
});
|
||||
|
||||
test('facebook says every page is connected instead of network_taken in multi-account mode', function () {
|
||||
config()->set('trypost.allow_multiple_social_accounts', true);
|
||||
|
||||
SocialAccount::factory()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'platform' => Platform::Facebook,
|
||||
'platform_user_id' => 'page-1',
|
||||
]);
|
||||
|
||||
session(['social_connect_workspace' => $this->workspace->id]);
|
||||
|
||||
$socialiteUser = Mockery::mock(SocialiteUser::class);
|
||||
$socialiteUser->shouldReceive('getId')->andReturn('fb-user');
|
||||
$socialiteUser->token = 'user-token';
|
||||
|
||||
$driverMock = Mockery::mock();
|
||||
$driverMock->shouldReceive('usingGraphVersion')->andReturnSelf();
|
||||
$driverMock->shouldReceive('user')->andReturn($socialiteUser);
|
||||
|
||||
Socialite::shouldReceive('driver')
|
||||
->with('facebook')
|
||||
->andReturn($driverMock);
|
||||
|
||||
Http::fake([
|
||||
'https://graph.facebook.com/*/me/accounts*' => Http::response([
|
||||
'data' => [
|
||||
['id' => 'page-1', 'name' => 'Only Page', 'access_token' => 'page-token'],
|
||||
],
|
||||
], 200),
|
||||
'https://graph.facebook.com/*' => Http::response(['id' => 'fb-user', 'name' => 'Me'], 200),
|
||||
]);
|
||||
|
||||
$this->actingAs($this->user)
|
||||
->get(route('app.social.facebook.callback'))
|
||||
->assertOk()
|
||||
->assertInertia(fn (AssertableInertia $page) => $page
|
||||
->where('success', false)
|
||||
->where('message', __('accounts.popup_callback.all_connected'))
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@
|
|||
use App\Models\SocialAccount;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Inertia\Testing\AssertableInertia;
|
||||
use Laravel\Socialite\Facades\Socialite;
|
||||
use Laravel\Socialite\Two\User as SocialiteUser;
|
||||
|
|
@ -83,8 +85,8 @@
|
|||
$response->assertInertia(fn (AssertableInertia $page) => $page->where('message', 'Session expired. Please try again.'));
|
||||
});
|
||||
|
||||
test('user can connect multiple instagram accounts in self-hosted mode', function () {
|
||||
config()->set('trypost.self_hosted', true);
|
||||
test('user can connect multiple instagram accounts when multiple social accounts are allowed', function () {
|
||||
config()->set('trypost.allow_multiple_social_accounts', true);
|
||||
|
||||
SocialAccount::factory()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
|
|
@ -122,7 +124,7 @@
|
|||
});
|
||||
|
||||
test('instagram callback shows network_taken when the network is already connected', function () {
|
||||
config()->set('trypost.self_hosted', false);
|
||||
config()->set('trypost.allow_multiple_social_accounts', false);
|
||||
|
||||
SocialAccount::factory()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
|
|
@ -184,3 +186,165 @@
|
|||
|
||||
$response->assertRedirect(route('app.workspaces.create'));
|
||||
});
|
||||
|
||||
test('instagram callback refuses an identity already connected via the facebook variant', function () {
|
||||
config()->set('trypost.allow_multiple_social_accounts', true);
|
||||
|
||||
SocialAccount::factory()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'platform' => Platform::InstagramFacebook,
|
||||
'platform_user_id' => 'shared-ig-id',
|
||||
'username' => 'brand',
|
||||
]);
|
||||
|
||||
session([
|
||||
'social_connect_workspace' => $this->workspace->id,
|
||||
]);
|
||||
|
||||
$socialiteUser = Mockery::mock(SocialiteUser::class);
|
||||
$socialiteUser->shouldReceive('getId')->andReturn('shared-ig-id');
|
||||
$socialiteUser->shouldReceive('getNickname')->andReturn('brand');
|
||||
$socialiteUser->shouldReceive('getName')->andReturn('Brand');
|
||||
$socialiteUser->shouldReceive('getAvatar')->andReturn(null);
|
||||
$socialiteUser->token = 'test-access-token';
|
||||
$socialiteUser->refreshToken = 'test-refresh-token';
|
||||
$socialiteUser->expiresIn = 5184000;
|
||||
$socialiteUser->user = ['account_type' => 'BUSINESS'];
|
||||
|
||||
Socialite::shouldReceive('driver')
|
||||
->with('instagram')
|
||||
->andReturn(Mockery::mock([
|
||||
'user' => $socialiteUser,
|
||||
]));
|
||||
|
||||
$response = $this->actingAs($this->user)->get(route('app.social.instagram.callback'));
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertInertia(fn (AssertableInertia $page) => $page->where('success', false));
|
||||
$response->assertInertia(fn (AssertableInertia $page) => $page->where('message', __('accounts.popup_callback.all_connected')));
|
||||
|
||||
expect($this->workspace->socialAccounts()->where('platform_user_id', 'shared-ig-id')->count())->toBe(1);
|
||||
});
|
||||
|
||||
test('instagram callback reconnects the original card', function () {
|
||||
$account = SocialAccount::factory()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'platform' => Platform::Instagram,
|
||||
'platform_user_id' => '12345678',
|
||||
'username' => 'old',
|
||||
'access_token' => 'expired-token',
|
||||
'status' => Status::TokenExpired,
|
||||
]);
|
||||
|
||||
session([
|
||||
'social_connect_workspace' => $this->workspace->id,
|
||||
'social_reconnect_id' => $account->id,
|
||||
]);
|
||||
|
||||
$socialiteUser = Mockery::mock(SocialiteUser::class);
|
||||
$socialiteUser->shouldReceive('getId')->andReturn('12345678');
|
||||
$socialiteUser->shouldReceive('getNickname')->andReturn('testuser');
|
||||
$socialiteUser->shouldReceive('getName')->andReturn('Test User');
|
||||
$socialiteUser->shouldReceive('getAvatar')->andReturn(null);
|
||||
$socialiteUser->token = 'fresh-access-token';
|
||||
$socialiteUser->refreshToken = 'fresh-refresh-token';
|
||||
$socialiteUser->expiresIn = 5184000;
|
||||
$socialiteUser->user = ['account_type' => 'BUSINESS'];
|
||||
|
||||
Socialite::shouldReceive('driver')
|
||||
->with('instagram')
|
||||
->andReturn(Mockery::mock(['user' => $socialiteUser]));
|
||||
|
||||
$this->actingAs($this->user)
|
||||
->get(route('app.social.instagram.callback'))
|
||||
->assertOk()
|
||||
->assertInertia(fn (AssertableInertia $page) => $page
|
||||
->where('success', true)
|
||||
->where('message', __('accounts.popup_callback.reconnected'))
|
||||
);
|
||||
|
||||
expect($this->workspace->socialAccounts()->count())->toBe(1)
|
||||
->and($account->fresh()->access_token)->toBe('fresh-access-token')
|
||||
->and($account->fresh()->username)->toBe('testuser')
|
||||
->and($account->fresh()->status)->toBe(Status::Connected);
|
||||
});
|
||||
|
||||
test('instagram reconnect that authorizes another account says so instead of connecting', function () {
|
||||
$account = SocialAccount::factory()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'platform' => Platform::Instagram,
|
||||
'platform_user_id' => '12345678',
|
||||
'username' => 'old',
|
||||
]);
|
||||
|
||||
session([
|
||||
'social_connect_workspace' => $this->workspace->id,
|
||||
'social_reconnect_id' => $account->id,
|
||||
]);
|
||||
|
||||
$socialiteUser = Mockery::mock(SocialiteUser::class);
|
||||
$socialiteUser->shouldReceive('getId')->andReturn('99999999');
|
||||
$socialiteUser->shouldReceive('getNickname')->andReturn('someone-else');
|
||||
$socialiteUser->shouldReceive('getName')->andReturn('Someone Else');
|
||||
$socialiteUser->shouldReceive('getAvatar')->andReturn(null);
|
||||
$socialiteUser->token = 'other-access-token';
|
||||
$socialiteUser->refreshToken = 'other-refresh-token';
|
||||
$socialiteUser->expiresIn = 5184000;
|
||||
$socialiteUser->user = ['account_type' => 'BUSINESS'];
|
||||
|
||||
Socialite::shouldReceive('driver')
|
||||
->with('instagram')
|
||||
->andReturn(Mockery::mock(['user' => $socialiteUser]));
|
||||
|
||||
$this->actingAs($this->user)
|
||||
->get(route('app.social.instagram.callback'))
|
||||
->assertOk()
|
||||
->assertInertia(fn (AssertableInertia $page) => $page
|
||||
->where('success', false)
|
||||
->where('message', __('accounts.popup_callback.wrong_account'))
|
||||
);
|
||||
|
||||
expect($this->workspace->socialAccounts()->count())->toBe(1)
|
||||
->and($account->fresh()->platform_user_id)->toBe('12345678')
|
||||
->and($account->fresh()->username)->toBe('old');
|
||||
});
|
||||
|
||||
test('a connect racing another on the same network says it is busy without filing an error', function () {
|
||||
Log::spy();
|
||||
|
||||
session(['social_connect_workspace' => $this->workspace->id]);
|
||||
|
||||
$socialiteUser = Mockery::mock(SocialiteUser::class);
|
||||
$socialiteUser->shouldReceive('getId')->andReturn('12345678');
|
||||
$socialiteUser->shouldReceive('getNickname')->andReturn('testuser');
|
||||
$socialiteUser->shouldReceive('getName')->andReturn('Test User');
|
||||
$socialiteUser->shouldReceive('getAvatar')->andReturn(null);
|
||||
$socialiteUser->token = 'test-access-token';
|
||||
$socialiteUser->refreshToken = 'test-refresh-token';
|
||||
$socialiteUser->expiresIn = 5184000;
|
||||
$socialiteUser->user = ['account_type' => 'BUSINESS'];
|
||||
|
||||
Socialite::shouldReceive('driver')
|
||||
->with('instagram')
|
||||
->andReturn(Mockery::mock(['user' => $socialiteUser]));
|
||||
|
||||
$lock = Cache::lock("social_connect:{$this->workspace->id}:".Platform::Instagram->network(), 10);
|
||||
|
||||
expect($lock->get())->toBeTrue();
|
||||
|
||||
try {
|
||||
$this->actingAs($this->user)
|
||||
->get(route('app.social.instagram.callback'))
|
||||
->assertOk()
|
||||
->assertInertia(fn (AssertableInertia $page) => $page
|
||||
->where('success', false)
|
||||
->where('message', __('accounts.popup_callback.busy'))
|
||||
);
|
||||
} finally {
|
||||
$lock->release();
|
||||
}
|
||||
|
||||
expect($this->workspace->socialAccounts()->count())->toBe(0);
|
||||
|
||||
Log::shouldNotHaveReceived('error');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -319,7 +319,7 @@
|
|||
expect($this->workspace->socialAccounts()->where('platform', Platform::InstagramFacebook)->count())->toBe(0);
|
||||
});
|
||||
|
||||
test('instagram-facebook select connects the page in self-hosted mode', function () {
|
||||
test('instagram-facebook select skips deferred onboarding progress in self-hosted mode', function () {
|
||||
config()->set('trypost.self_hosted', true);
|
||||
|
||||
session([
|
||||
|
|
@ -372,6 +372,52 @@
|
|||
);
|
||||
});
|
||||
|
||||
test('instagram-facebook reconnect updates the original card via connectIdentity', function () {
|
||||
$account = SocialAccount::factory()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'platform' => Platform::InstagramFacebook,
|
||||
'platform_user_id' => 'ig-old',
|
||||
'username' => 'oldbiz',
|
||||
'access_token' => 'expired-token',
|
||||
]);
|
||||
|
||||
session([
|
||||
'social_connect_workspace' => $this->workspace->id,
|
||||
'instagram_facebook_oauth' => [
|
||||
'user_token' => 'user-token',
|
||||
'reconnect_id' => $account->id,
|
||||
'pages' => [
|
||||
[
|
||||
'page_id' => 'page-1',
|
||||
'page_name' => 'My Page',
|
||||
'page_access_token' => 'fresh-token',
|
||||
'ig_id' => 'ig-old',
|
||||
'ig_username' => 'mybiz',
|
||||
'ig_name' => 'My Biz',
|
||||
'ig_picture' => null,
|
||||
],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($this->user)->post(route('app.social.instagram-facebook.select'), [
|
||||
'page_id' => 'page-1',
|
||||
]);
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertInertia(fn (AssertableInertia $page) => $page
|
||||
->component('accounts/PopupCallback')
|
||||
->where('success', true)
|
||||
->where('message', __('accounts.popup_callback.reconnected'))
|
||||
);
|
||||
|
||||
$account->refresh();
|
||||
|
||||
expect($this->workspace->socialAccounts()->where('platform', Platform::InstagramFacebook)->count())->toBe(1)
|
||||
->and($account->username)->toBe('mybiz')
|
||||
->and($account->access_token)->toBe('fresh-token');
|
||||
});
|
||||
|
||||
test('instagram-facebook select page returns popup callback when the session expired', function () {
|
||||
$this->actingAs($this->user)
|
||||
->get(route('app.social.instagram-facebook.select-page'))
|
||||
|
|
@ -385,7 +431,7 @@
|
|||
});
|
||||
|
||||
test('instagram-facebook select shows network_taken when a standalone instagram is already connected', function () {
|
||||
config()->set('trypost.self_hosted', false);
|
||||
config()->set('trypost.allow_multiple_social_accounts', false);
|
||||
|
||||
SocialAccount::factory()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
|
|
@ -423,3 +469,50 @@
|
|||
|
||||
expect($this->workspace->socialAccounts()->whereIn('platform', [Platform::Instagram->value, Platform::InstagramFacebook->value])->count())->toBe(1);
|
||||
});
|
||||
|
||||
test('instagram-facebook callback hides an instagram already connected standalone in multi-account mode', function () {
|
||||
config()->set('trypost.allow_multiple_social_accounts', true);
|
||||
|
||||
SocialAccount::factory()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'platform' => Platform::Instagram,
|
||||
'platform_user_id' => 'shared-ig',
|
||||
]);
|
||||
|
||||
session(['social_connect_workspace' => $this->workspace->id]);
|
||||
|
||||
$socialiteUser = Mockery::mock(SocialiteUser::class);
|
||||
$socialiteUser->token = 'test-user-token';
|
||||
|
||||
Socialite::shouldReceive('driver')
|
||||
->with('facebook')
|
||||
->andReturn(Mockery::mock(['user' => $socialiteUser]));
|
||||
|
||||
Http::fake([
|
||||
'https://graph.facebook.com/*/me/accounts*' => Http::response([
|
||||
'data' => [
|
||||
[
|
||||
'id' => 'page-1',
|
||||
'name' => 'Shared Page',
|
||||
'access_token' => 'page-token',
|
||||
'instagram_business_account' => ['id' => 'shared-ig'],
|
||||
],
|
||||
],
|
||||
], 200),
|
||||
'https://graph.facebook.com/*' => Http::response([
|
||||
'id' => 'shared-ig',
|
||||
'username' => 'shared',
|
||||
'name' => 'Shared',
|
||||
], 200),
|
||||
]);
|
||||
|
||||
$this->actingAs($this->user)
|
||||
->get(route('app.social.instagram-facebook.callback'))
|
||||
->assertOk()
|
||||
->assertInertia(fn (AssertableInertia $page) => $page->where('success', false));
|
||||
|
||||
expect($this->workspace->socialAccounts()
|
||||
->where('platform', Platform::InstagramFacebook->value)
|
||||
->exists())->toBeFalse()
|
||||
->and($this->workspace->socialAccounts()->count())->toBe(1);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -478,7 +478,7 @@ function captureLinkedInConnectScopes(object $test): array
|
|||
});
|
||||
|
||||
test('selecting the person shows network_taken when a linkedin page already occupies the network', function () {
|
||||
config()->set('trypost.self_hosted', false);
|
||||
config()->set('trypost.allow_multiple_social_accounts', false);
|
||||
|
||||
SocialAccount::factory()->linkedinPage()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
|
|
@ -509,8 +509,8 @@ function captureLinkedInConnectScopes(object $test): array
|
|||
]);
|
||||
});
|
||||
|
||||
test('user can connect multiple linkedin organizations in self-hosted mode', function () {
|
||||
config()->set('trypost.self_hosted', true);
|
||||
test('user can connect multiple linkedin organizations when multiple social accounts are allowed', function () {
|
||||
config()->set('trypost.allow_multiple_social_accounts', true);
|
||||
|
||||
SocialAccount::factory()->linkedinPage()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
|
|
@ -539,3 +539,404 @@ function captureLinkedInConnectScopes(object $test): array
|
|||
|
||||
expect($this->workspace->socialAccounts()->where('platform', Platform::LinkedInPage)->count())->toBe(2);
|
||||
});
|
||||
|
||||
test('linkedin reconnect keeps the original profile card', function () {
|
||||
$account = SocialAccount::factory()->linkedin()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'platform_user_id' => 'person-123',
|
||||
'username' => 'old',
|
||||
'access_token' => 'expired-token',
|
||||
]);
|
||||
|
||||
session([
|
||||
'social_reconnect_id' => $account->id,
|
||||
'linkedin_pending' => [
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'token' => 'fresh-access-token',
|
||||
'refresh_token' => 'fresh-refresh-token',
|
||||
'expires_in' => 5184000,
|
||||
'approved_scopes' => ['openid', 'profile', 'email', 'w_member_social'],
|
||||
'person' => ['id' => 'person-123', 'name' => 'John Doe', 'avatar' => null, 'vanity_name' => 'johndoe'],
|
||||
'organizations' => [
|
||||
['id' => 111, 'name' => 'My Company', 'vanity_name' => 'myco', 'logo' => null],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$this->actingAs($this->user)
|
||||
->post(route('app.social.linkedin.select'), ['type' => 'person'])
|
||||
->assertOk()
|
||||
->assertInertia(fn (Assert $page) => $page
|
||||
->where('success', true)
|
||||
->where('message', __('accounts.popup_callback.reconnected'))
|
||||
);
|
||||
|
||||
expect($this->workspace->socialAccounts()->count())->toBe(1)
|
||||
->and($account->fresh()->access_token)->toBe('fresh-access-token')
|
||||
->and($account->fresh()->username)->toBe('johndoe');
|
||||
});
|
||||
|
||||
test('linkedin reconnect keeps the original page card', function () {
|
||||
$account = SocialAccount::factory()->linkedinPage()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'platform_user_id' => '111',
|
||||
'username' => 'old-page',
|
||||
'access_token' => 'expired-token',
|
||||
]);
|
||||
|
||||
session([
|
||||
'social_reconnect_id' => $account->id,
|
||||
'linkedin_pending' => [
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'token' => 'fresh-access-token',
|
||||
'refresh_token' => 'fresh-refresh-token',
|
||||
'expires_in' => 5184000,
|
||||
'approved_scopes' => ['openid', 'w_organization_social'],
|
||||
'person' => ['id' => 'person-123', 'name' => 'John Doe', 'avatar' => null, 'vanity_name' => 'johndoe'],
|
||||
'organizations' => [
|
||||
['id' => 111, 'name' => 'My Company', 'vanity_name' => 'myco', 'logo' => null],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$this->actingAs($this->user)
|
||||
->post(route('app.social.linkedin.select'), [
|
||||
'type' => 'organization',
|
||||
'organization_id' => 111,
|
||||
])
|
||||
->assertOk()
|
||||
->assertInertia(fn (Assert $page) => $page
|
||||
->where('success', true)
|
||||
->where('message', __('accounts.popup_callback.reconnected'))
|
||||
);
|
||||
|
||||
expect($this->workspace->socialAccounts()->count())->toBe(1)
|
||||
->and($account->fresh()->platform)->toBe(Platform::LinkedInPage)
|
||||
->and($account->fresh()->access_token)->toBe('fresh-access-token')
|
||||
->and($account->fresh()->username)->toBe('myco');
|
||||
});
|
||||
|
||||
test('linkedin reconnect rejects picking a different identity than the card', function () {
|
||||
$account = SocialAccount::factory()->linkedin()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'platform_user_id' => 'person-123',
|
||||
]);
|
||||
|
||||
session([
|
||||
'social_reconnect_id' => $account->id,
|
||||
'linkedin_pending' => [
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'token' => 'fresh-access-token',
|
||||
'refresh_token' => 'fresh-refresh-token',
|
||||
'expires_in' => 5184000,
|
||||
'approved_scopes' => ['openid', 'w_organization_social'],
|
||||
'person' => ['id' => 'person-123', 'name' => 'John Doe', 'avatar' => null, 'vanity_name' => 'johndoe'],
|
||||
'organizations' => [
|
||||
['id' => 111, 'name' => 'My Company', 'vanity_name' => 'myco', 'logo' => null],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$this->actingAs($this->user)
|
||||
->post(route('app.social.linkedin.select'), [
|
||||
'type' => 'organization',
|
||||
'organization_id' => 111,
|
||||
])
|
||||
->assertOk()
|
||||
->assertInertia(fn (Assert $page) => $page
|
||||
->where('success', false)
|
||||
->where('message', __('accounts.popup_callback.wrong_account'))
|
||||
);
|
||||
|
||||
expect($account->fresh()->platform_user_id)->toBe('person-123')
|
||||
->and($this->workspace->socialAccounts()->count())->toBe(1);
|
||||
});
|
||||
|
||||
test('linkedin identity picker hides identities that are not the reconnect card', function () {
|
||||
$account = SocialAccount::factory()->linkedinPage()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'platform_user_id' => '111',
|
||||
]);
|
||||
|
||||
session([
|
||||
'social_reconnect_id' => $account->id,
|
||||
'linkedin_pending' => [
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'token' => 'fresh-access-token',
|
||||
'refresh_token' => 'fresh-refresh-token',
|
||||
'expires_in' => 5184000,
|
||||
'approved_scopes' => ['openid', 'w_organization_social'],
|
||||
'person' => ['id' => 'person-123', 'name' => 'John Doe', 'avatar' => null, 'vanity_name' => 'johndoe'],
|
||||
'organizations' => [
|
||||
['id' => 111, 'name' => 'My Company', 'vanity_name' => 'myco', 'logo' => null],
|
||||
['id' => 222, 'name' => 'Other Co', 'vanity_name' => 'other', 'logo' => null],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$this->actingAs($this->user)
|
||||
->get(route('app.social.linkedin.select-identity'))
|
||||
->assertOk()
|
||||
->assertInertia(fn (Assert $page) => $page
|
||||
->component('accounts/LinkedInSelect')
|
||||
->where('person', null)
|
||||
->has('organizations', 1)
|
||||
->where('organizations.0.id', 111)
|
||||
);
|
||||
});
|
||||
|
||||
test('select-identity hides an organization that is already connected', function () {
|
||||
config()->set('trypost.allow_multiple_social_accounts', true);
|
||||
|
||||
SocialAccount::factory()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'platform' => Platform::LinkedInPage,
|
||||
'platform_user_id' => '123456',
|
||||
]);
|
||||
|
||||
session(['linkedin_pending' => [
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'token' => 'test-access-token',
|
||||
'refresh_token' => 'test-refresh-token',
|
||||
'expires_in' => 5184000,
|
||||
'approved_scopes' => ['openid', 'profile', 'email', 'w_member_social'],
|
||||
'person' => ['id' => 'person-123', 'name' => 'John Doe', 'avatar' => null, 'vanity_name' => 'johndoe'],
|
||||
'organizations' => [
|
||||
['id' => 123456, 'name' => 'Taken Company', 'vanity_name' => 'taken', 'logo' => null],
|
||||
['id' => 999, 'name' => 'Free Company', 'vanity_name' => 'free', 'logo' => null],
|
||||
],
|
||||
]]);
|
||||
|
||||
$this->actingAs($this->user)
|
||||
->get(route('app.social.linkedin.select-identity'))
|
||||
->assertOk()
|
||||
->assertInertia(fn (Assert $page) => $page
|
||||
->component('accounts/LinkedInSelect')
|
||||
->where('person.name', 'John Doe')
|
||||
->has('organizations', 1)
|
||||
->where('organizations.0.name', 'Free Company')
|
||||
);
|
||||
});
|
||||
|
||||
test('select-identity reports the network is taken when nothing is connectable', function () {
|
||||
config()->set('trypost.allow_multiple_social_accounts', false);
|
||||
|
||||
SocialAccount::factory()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'platform' => Platform::LinkedIn,
|
||||
'platform_user_id' => 'person-123',
|
||||
]);
|
||||
|
||||
session(['linkedin_pending' => [
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'token' => 'test-access-token',
|
||||
'refresh_token' => 'test-refresh-token',
|
||||
'expires_in' => 5184000,
|
||||
'approved_scopes' => ['openid', 'profile', 'email', 'w_member_social'],
|
||||
'person' => ['id' => 'person-123', 'name' => 'John Doe', 'avatar' => null, 'vanity_name' => 'johndoe'],
|
||||
'organizations' => [],
|
||||
]]);
|
||||
|
||||
$this->actingAs($this->user)
|
||||
->get(route('app.social.linkedin.select-identity'))
|
||||
->assertOk()
|
||||
->assertInertia(fn (Assert $page) => $page
|
||||
->component('accounts/PopupCallback')
|
||||
->where('success', false)
|
||||
->where('message', __('accounts.popup_callback.network_taken'))
|
||||
);
|
||||
|
||||
expect(session()->has('linkedin_pending'))->toBeFalse();
|
||||
});
|
||||
|
||||
test('select-identity keeps the picker empty state when linkedin offers nothing', function () {
|
||||
config()->set('trypost.platforms.linkedin.enabled', false);
|
||||
config()->set('trypost.platforms.linkedin-page.enabled', true);
|
||||
|
||||
session(['linkedin_pending' => [
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'token' => 'test-access-token',
|
||||
'refresh_token' => 'test-refresh-token',
|
||||
'expires_in' => 5184000,
|
||||
'approved_scopes' => ['openid', 'profile', 'email', 'w_member_social'],
|
||||
'person' => ['id' => 'person-123', 'name' => 'John Doe', 'avatar' => null, 'vanity_name' => 'johndoe'],
|
||||
'organizations' => [],
|
||||
]]);
|
||||
|
||||
$this->actingAs($this->user)
|
||||
->get(route('app.social.linkedin.select-identity'))
|
||||
->assertOk()
|
||||
->assertInertia(fn (Assert $page) => $page
|
||||
->component('accounts/LinkedInSelect')
|
||||
->where('person', null)
|
||||
->has('organizations', 0)
|
||||
);
|
||||
});
|
||||
|
||||
test('select-identity does not defer onboarding progress back onto its own route', function () {
|
||||
config()->set('trypost.platforms.linkedin.enabled', false);
|
||||
config()->set('trypost.platforms.linkedin-page.enabled', true);
|
||||
|
||||
session(['linkedin_pending' => [
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'token' => 'test-access-token',
|
||||
'refresh_token' => 'test-refresh-token',
|
||||
'expires_in' => 5184000,
|
||||
'approved_scopes' => ['openid', 'profile', 'email', 'w_member_social'],
|
||||
'person' => ['id' => 'person-123', 'name' => 'John Doe', 'avatar' => null, 'vanity_name' => 'johndoe'],
|
||||
'organizations' => [],
|
||||
]]);
|
||||
|
||||
$this->actingAs($this->user)
|
||||
->get(route('app.social.linkedin.select-identity'))
|
||||
->assertOk()
|
||||
->assertInertia(fn (Assert $page) => $page
|
||||
->component('accounts/LinkedInSelect')
|
||||
->where('onboardingProgress', false)
|
||||
);
|
||||
|
||||
expect(session()->has('linkedin_pending'))->toBeFalse();
|
||||
});
|
||||
|
||||
test('select-identity reports a wrong account when a profile reconnect authorized another member', function () {
|
||||
$account = SocialAccount::factory()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'platform' => Platform::LinkedIn,
|
||||
'platform_user_id' => 'person-123',
|
||||
]);
|
||||
|
||||
session([
|
||||
'social_connect_workspace' => $this->workspace->id,
|
||||
'social_reconnect_id' => $account->id,
|
||||
'linkedin_pending' => [
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'token' => 'test-access-token',
|
||||
'refresh_token' => 'test-refresh-token',
|
||||
'expires_in' => 5184000,
|
||||
'approved_scopes' => ['openid', 'profile', 'email', 'w_member_social'],
|
||||
'person' => ['id' => 'person-999', 'name' => 'Someone Else', 'avatar' => null, 'vanity_name' => null],
|
||||
'organizations' => [],
|
||||
],
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($this->user)->get(route('app.social.linkedin.select-identity'));
|
||||
|
||||
$response->assertInertia(fn (Assert $page) => $page->where('success', false));
|
||||
$response->assertInertia(fn (Assert $page) => $page->where('message', __('accounts.popup_callback.wrong_account')));
|
||||
|
||||
expect(session('linkedin_pending'))->toBeNull()
|
||||
->and($account->fresh()->platform_user_id)->toBe('person-123');
|
||||
});
|
||||
|
||||
test('select-identity still reports a missing page when a page reconnect lost its organization', function () {
|
||||
$account = SocialAccount::factory()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'platform' => Platform::LinkedInPage,
|
||||
'platform_user_id' => 'org-123',
|
||||
]);
|
||||
|
||||
session([
|
||||
'social_connect_workspace' => $this->workspace->id,
|
||||
'social_reconnect_id' => $account->id,
|
||||
'linkedin_pending' => [
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'token' => 'test-access-token',
|
||||
'refresh_token' => 'test-refresh-token',
|
||||
'expires_in' => 5184000,
|
||||
'approved_scopes' => ['openid', 'w_organization_social'],
|
||||
'person' => ['id' => 'person-123', 'name' => 'John Doe', 'avatar' => null, 'vanity_name' => null],
|
||||
'organizations' => [],
|
||||
],
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($this->user)->get(route('app.social.linkedin.select-identity'));
|
||||
|
||||
$response->assertInertia(fn (Assert $page) => $page->where('success', false));
|
||||
$response->assertInertia(fn (Assert $page) => $page->where('message', __('accounts.popup_callback.page_not_found')));
|
||||
});
|
||||
|
||||
/**
|
||||
* connectIdentity() refuses a mismatched reconnect on its own, so these guards
|
||||
* are not what produces the error — they are what stops the avatar download
|
||||
* that building the connect payload would otherwise run first. Without them the
|
||||
* suite still passes and the wasted fetch comes back unnoticed.
|
||||
*/
|
||||
test('rejecting a mismatched organization reconnect never downloads its logo', function () {
|
||||
Storage::fake();
|
||||
|
||||
$account = SocialAccount::factory()->linkedin()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'platform_user_id' => 'person-123',
|
||||
]);
|
||||
|
||||
session([
|
||||
'social_reconnect_id' => $account->id,
|
||||
'linkedin_pending' => [
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'token' => 'fresh-access-token',
|
||||
'refresh_token' => 'fresh-refresh-token',
|
||||
'expires_in' => 5184000,
|
||||
'approved_scopes' => ['openid', 'w_organization_social'],
|
||||
'person' => ['id' => 'person-123', 'name' => 'John Doe', 'avatar' => null, 'vanity_name' => 'johndoe'],
|
||||
'organizations' => [
|
||||
['id' => 111, 'name' => 'My Company', 'vanity_name' => 'myco', 'logo' => 'https://93.184.216.34/logo.jpg'],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
// A public IP literal as the host lets SafeHttpFetcher's SSRF guard pass
|
||||
// without a real DNS lookup; Http::fake() intercepts before any network I/O.
|
||||
Http::fake([
|
||||
'https://93.184.216.34/logo.jpg' => Http::response('fake-image-bytes', 200, ['Content-Type' => 'image/jpeg']),
|
||||
]);
|
||||
|
||||
$this->actingAs($this->user)
|
||||
->post(route('app.social.linkedin.select'), ['type' => 'organization', 'organization_id' => 111])
|
||||
->assertOk()
|
||||
->assertInertia(fn (Assert $page) => $page
|
||||
->where('success', false)
|
||||
->where('message', __('accounts.popup_callback.wrong_account'))
|
||||
);
|
||||
|
||||
Http::assertNotSent(fn ($request) => $request->url() === 'https://93.184.216.34/logo.jpg');
|
||||
|
||||
expect($account->fresh()->platform_user_id)->toBe('person-123');
|
||||
});
|
||||
|
||||
test('rejecting a mismatched profile reconnect never downloads its avatar', function () {
|
||||
Storage::fake();
|
||||
|
||||
$account = SocialAccount::factory()->linkedin()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'platform_user_id' => 'person-123',
|
||||
]);
|
||||
|
||||
session([
|
||||
'social_reconnect_id' => $account->id,
|
||||
'linkedin_pending' => [
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'token' => 'fresh-access-token',
|
||||
'refresh_token' => 'fresh-refresh-token',
|
||||
'expires_in' => 5184000,
|
||||
'approved_scopes' => ['openid', 'profile', 'w_member_social'],
|
||||
'person' => ['id' => 'person-999', 'name' => 'Someone Else', 'avatar' => 'https://93.184.216.34/avatar.jpg', 'vanity_name' => 'someone'],
|
||||
'organizations' => [],
|
||||
],
|
||||
]);
|
||||
|
||||
Http::fake([
|
||||
'https://93.184.216.34/avatar.jpg' => Http::response('fake-image-bytes', 200, ['Content-Type' => 'image/jpeg']),
|
||||
]);
|
||||
|
||||
$this->actingAs($this->user)
|
||||
->post(route('app.social.linkedin.select'), ['type' => 'person'])
|
||||
->assertOk()
|
||||
->assertInertia(fn (Assert $page) => $page
|
||||
->where('success', false)
|
||||
->where('message', __('accounts.popup_callback.wrong_account'))
|
||||
);
|
||||
|
||||
Http::assertNotSent(fn ($request) => $request->url() === 'https://93.184.216.34/avatar.jpg');
|
||||
|
||||
expect($account->fresh()->platform_user_id)->toBe('person-123');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -144,8 +144,8 @@
|
|||
$response->assertInertia(fn (AssertableInertia $page) => $page->where('message', 'Session expired. Please try again.'));
|
||||
});
|
||||
|
||||
test('user can connect multiple mastodon accounts in self-hosted mode', function () {
|
||||
config()->set('trypost.self_hosted', true);
|
||||
test('user can connect multiple mastodon accounts when multiple social accounts are allowed', function () {
|
||||
config()->set('trypost.allow_multiple_social_accounts', true);
|
||||
|
||||
SocialAccount::factory()->mastodon()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
|
|
@ -184,6 +184,47 @@
|
|||
expect($this->workspace->socialAccounts()->where('platform', Platform::Mastodon)->count())->toBe(2);
|
||||
});
|
||||
|
||||
test('mastodon callback shows network_taken when the network is already connected', function () {
|
||||
config()->set('trypost.allow_multiple_social_accounts', false);
|
||||
|
||||
SocialAccount::factory()->mastodon()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'platform_user_id' => '123456789',
|
||||
]);
|
||||
|
||||
session([
|
||||
'mastodon_instance' => 'https://mastodon.social',
|
||||
'mastodon_client_id' => 'test-client-id',
|
||||
'mastodon_client_secret' => 'test-client-secret',
|
||||
'mastodon_oauth_state' => 'test-state',
|
||||
'social_connect_workspace' => $this->workspace->id,
|
||||
]);
|
||||
|
||||
Http::fake([
|
||||
'https://mastodon.social/oauth/token' => Http::response([
|
||||
'access_token' => 'new-access-token',
|
||||
'token_type' => 'Bearer',
|
||||
], 200),
|
||||
'https://mastodon.social/api/v1/accounts/verify_credentials' => Http::response([
|
||||
'id' => '987654321',
|
||||
'username' => 'anotheruser',
|
||||
'acct' => 'anotheruser',
|
||||
'display_name' => 'Another User',
|
||||
], 200),
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($this->user)->get(route('app.social.mastodon.callback', [
|
||||
'code' => 'test-auth-code',
|
||||
'state' => 'test-state',
|
||||
]));
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertInertia(fn (AssertableInertia $page) => $page->where('success', false));
|
||||
$response->assertInertia(fn (AssertableInertia $page) => $page->where('message', __('accounts.popup_callback.network_taken')));
|
||||
|
||||
expect($this->workspace->socialAccounts()->where('platform', Platform::Mastodon)->count())->toBe(1);
|
||||
});
|
||||
|
||||
test('mastodon connection validates instance url', function () {
|
||||
$response = $this->actingAs($this->user)->post(route('app.social.mastodon.authorize'), [
|
||||
'instance' => 'not-a-valid-url',
|
||||
|
|
@ -212,3 +253,97 @@
|
|||
|
||||
expect(session('mastodon_instance'))->toBe('https://techhub.social');
|
||||
});
|
||||
|
||||
test('mastodon callback reconnects the original card', function () {
|
||||
$account = SocialAccount::factory()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'platform' => Platform::Mastodon,
|
||||
'platform_user_id' => '123456789',
|
||||
'username' => 'old',
|
||||
'access_token' => 'expired-token',
|
||||
'status' => Status::TokenExpired,
|
||||
]);
|
||||
|
||||
session([
|
||||
'mastodon_instance' => 'https://mastodon.social',
|
||||
'mastodon_client_id' => 'test-client-id',
|
||||
'mastodon_client_secret' => 'test-client-secret',
|
||||
'mastodon_oauth_state' => 'test-state',
|
||||
'social_connect_workspace' => $this->workspace->id,
|
||||
'social_reconnect_id' => $account->id,
|
||||
]);
|
||||
|
||||
Http::fake([
|
||||
'https://mastodon.social/oauth/token' => Http::response([
|
||||
'access_token' => 'fresh-access-token',
|
||||
'token_type' => 'Bearer',
|
||||
'scope' => 'read:accounts write:statuses write:media',
|
||||
'created_at' => time(),
|
||||
], 200),
|
||||
'https://mastodon.social/api/v1/accounts/verify_credentials' => Http::response([
|
||||
'id' => '123456789',
|
||||
'username' => 'testuser',
|
||||
'acct' => 'testuser',
|
||||
'display_name' => 'Test User',
|
||||
'avatar' => null,
|
||||
], 200),
|
||||
]);
|
||||
|
||||
$this->actingAs($this->user)
|
||||
->get(route('app.social.mastodon.callback', ['code' => 'test-auth-code', 'state' => 'test-state']))
|
||||
->assertOk()
|
||||
->assertInertia(fn (AssertableInertia $page) => $page
|
||||
->where('success', true)
|
||||
->where('message', __('accounts.popup_callback.reconnected'))
|
||||
);
|
||||
|
||||
expect($this->workspace->socialAccounts()->count())->toBe(1)
|
||||
->and($account->fresh()->access_token)->toBe('fresh-access-token')
|
||||
->and($account->fresh()->username)->toBe('testuser')
|
||||
->and($account->fresh()->status)->toBe(Status::Connected);
|
||||
});
|
||||
|
||||
test('mastodon reconnect that authorizes another account says so instead of connecting', function () {
|
||||
$account = SocialAccount::factory()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'platform' => Platform::Mastodon,
|
||||
'platform_user_id' => '123456789',
|
||||
'username' => 'old',
|
||||
]);
|
||||
|
||||
session([
|
||||
'mastodon_instance' => 'https://mastodon.social',
|
||||
'mastodon_client_id' => 'test-client-id',
|
||||
'mastodon_client_secret' => 'test-client-secret',
|
||||
'mastodon_oauth_state' => 'test-state',
|
||||
'social_connect_workspace' => $this->workspace->id,
|
||||
'social_reconnect_id' => $account->id,
|
||||
]);
|
||||
|
||||
Http::fake([
|
||||
'https://mastodon.social/oauth/token' => Http::response([
|
||||
'access_token' => 'other-access-token',
|
||||
'token_type' => 'Bearer',
|
||||
'scope' => 'read:accounts write:statuses write:media',
|
||||
'created_at' => time(),
|
||||
], 200),
|
||||
'https://mastodon.social/api/v1/accounts/verify_credentials' => Http::response([
|
||||
'id' => '999999999',
|
||||
'username' => 'someone-else',
|
||||
'acct' => 'someone-else',
|
||||
'display_name' => 'Someone Else',
|
||||
'avatar' => null,
|
||||
], 200),
|
||||
]);
|
||||
|
||||
$this->actingAs($this->user)
|
||||
->get(route('app.social.mastodon.callback', ['code' => 'test-auth-code', 'state' => 'test-state']))
|
||||
->assertOk()
|
||||
->assertInertia(fn (AssertableInertia $page) => $page
|
||||
->where('success', false)
|
||||
->where('message', __('accounts.popup_callback.wrong_account'))
|
||||
);
|
||||
|
||||
expect($this->workspace->socialAccounts()->count())->toBe(1)
|
||||
->and($account->fresh()->platform_user_id)->toBe('123456789');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -113,8 +113,8 @@
|
|||
$response->assertInertia(fn (AssertableInertia $page) => $page->where('message', 'Session expired. Please try again.'));
|
||||
});
|
||||
|
||||
test('user can connect multiple pinterest accounts in self-hosted mode', function () {
|
||||
config()->set('trypost.self_hosted', true);
|
||||
test('user can connect multiple pinterest accounts when multiple social accounts are allowed', function () {
|
||||
config()->set('trypost.allow_multiple_social_accounts', true);
|
||||
|
||||
SocialAccount::factory()->pinterest()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
|
|
@ -149,6 +149,81 @@
|
|||
expect($this->workspace->socialAccounts()->where('platform', Platform::Pinterest)->count())->toBe(2);
|
||||
});
|
||||
|
||||
test('pinterest callback shows network_taken when the network is already connected', function () {
|
||||
config()->set('trypost.allow_multiple_social_accounts', false);
|
||||
|
||||
SocialAccount::factory()->pinterest()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'platform_user_id' => 'pinterest_user_123',
|
||||
]);
|
||||
|
||||
session([
|
||||
'social_connect_workspace' => $this->workspace->id,
|
||||
]);
|
||||
|
||||
$socialiteUser = Mockery::mock(SocialiteUser::class);
|
||||
$socialiteUser->shouldReceive('getId')->andReturn('pinterest_user_456');
|
||||
$socialiteUser->shouldReceive('getNickname')->andReturn('anotherpinner');
|
||||
$socialiteUser->shouldReceive('getName')->andReturn('Another Pinterest User');
|
||||
$socialiteUser->shouldReceive('getAvatar')->andReturn(null);
|
||||
$socialiteUser->token = 'new-access-token';
|
||||
$socialiteUser->refreshToken = 'new-refresh-token';
|
||||
$socialiteUser->expiresIn = 2592000;
|
||||
$socialiteUser->approvedScopes = ['boards:read', 'boards:write', 'pins:read', 'pins:write', 'user_accounts:read'];
|
||||
|
||||
Socialite::shouldReceive('driver')
|
||||
->with('pinterest')
|
||||
->andReturn(Mockery::mock([
|
||||
'user' => $socialiteUser,
|
||||
]));
|
||||
|
||||
$response = $this->actingAs($this->user)->get(route('app.social.pinterest.callback'));
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertInertia(fn (AssertableInertia $page) => $page->where('success', false));
|
||||
$response->assertInertia(fn (AssertableInertia $page) => $page->where('message', __('accounts.popup_callback.network_taken')));
|
||||
|
||||
expect($this->workspace->socialAccounts()->where('platform', Platform::Pinterest)->count())->toBe(1);
|
||||
});
|
||||
|
||||
test('pinterest callback reconnects the same identity via updateOrCreate', function () {
|
||||
config()->set('trypost.allow_multiple_social_accounts', false);
|
||||
|
||||
SocialAccount::factory()->pinterest()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'platform_user_id' => 'pinterest_user_123',
|
||||
'username' => 'oldpinner',
|
||||
]);
|
||||
|
||||
session([
|
||||
'social_connect_workspace' => $this->workspace->id,
|
||||
]);
|
||||
|
||||
$socialiteUser = Mockery::mock(SocialiteUser::class);
|
||||
$socialiteUser->shouldReceive('getId')->andReturn('pinterest_user_123');
|
||||
$socialiteUser->shouldReceive('getNickname')->andReturn('pinner');
|
||||
$socialiteUser->shouldReceive('getName')->andReturn('Pinterest User');
|
||||
$socialiteUser->shouldReceive('getAvatar')->andReturn(null);
|
||||
$socialiteUser->token = 'new-access-token';
|
||||
$socialiteUser->refreshToken = 'new-refresh-token';
|
||||
$socialiteUser->expiresIn = 2592000;
|
||||
$socialiteUser->approvedScopes = ['boards:read', 'boards:write', 'pins:read', 'pins:write', 'user_accounts:read'];
|
||||
|
||||
Socialite::shouldReceive('driver')
|
||||
->with('pinterest')
|
||||
->andReturn(Mockery::mock([
|
||||
'user' => $socialiteUser,
|
||||
]));
|
||||
|
||||
$response = $this->actingAs($this->user)->get(route('app.social.pinterest.callback'));
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertInertia(fn (AssertableInertia $page) => $page->where('success', true));
|
||||
|
||||
expect($this->workspace->socialAccounts()->where('platform', Platform::Pinterest)->count())->toBe(1)
|
||||
->and($this->workspace->socialAccounts()->first()->username)->toBe('pinner');
|
||||
});
|
||||
|
||||
test('pinterest callback handles oauth errors gracefully', function () {
|
||||
session([
|
||||
'social_connect_workspace' => $this->workspace->id,
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@
|
|||
use App\Models\Workspace;
|
||||
use App\Services\Social\ConnectionVerifier;
|
||||
use App\Services\Social\Telegram\TelegramConnectCode;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
|
@ -121,7 +122,10 @@ function telegramUpdate(string $code, array $chat = []): array
|
|||
});
|
||||
|
||||
it('does not connect a second telegram channel when one is already connected', function () {
|
||||
config(['trypost.self_hosted' => false]);
|
||||
config([
|
||||
'trypost.self_hosted' => false,
|
||||
'trypost.allow_multiple_social_accounts' => false,
|
||||
]);
|
||||
Event::fake([TelegramConnectFailed::class]);
|
||||
|
||||
SocialAccount::factory()->telegram()->create([
|
||||
|
|
@ -147,9 +151,9 @@ function telegramUpdate(string $code, array $chat = []): array
|
|||
);
|
||||
});
|
||||
|
||||
it('connects a second telegram channel in self-hosted mode', function () {
|
||||
it('connects a second telegram channel when multiple social accounts are allowed', function () {
|
||||
Http::fake();
|
||||
config(['trypost.self_hosted' => true]);
|
||||
config(['trypost.allow_multiple_social_accounts' => true]);
|
||||
|
||||
SocialAccount::factory()->telegram()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
|
|
@ -170,7 +174,7 @@ function telegramUpdate(string $code, array $chat = []): array
|
|||
|
||||
it('reconnects an existing telegram channel', function () {
|
||||
Http::fake();
|
||||
config(['trypost.self_hosted' => false]);
|
||||
config(['trypost.allow_multiple_social_accounts' => false]);
|
||||
|
||||
SocialAccount::factory()->telegram()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
|
|
@ -189,6 +193,90 @@ function telegramUpdate(string $code, array $chat = []): array
|
|||
)->toBe(1);
|
||||
});
|
||||
|
||||
it('issues a connect code that carries the reconnect card', function () {
|
||||
$account = SocialAccount::factory()->telegram()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'platform_user_id' => '-1009999999999',
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($this->user)
|
||||
->postJson(route('app.social.telegram.connect', ['reconnect' => $account->id]))
|
||||
->assertOk();
|
||||
|
||||
expect(data_get(TelegramConnectCode::decode($response->json('code')), 'reconnect_id'))->toBe($account->id);
|
||||
});
|
||||
|
||||
it('keeps the reconnect card on its own chat when a different channel posts the code', function () {
|
||||
Http::fake();
|
||||
Event::fake([TelegramConnectFailed::class]);
|
||||
config(['trypost.allow_multiple_social_accounts' => false]);
|
||||
|
||||
$account = SocialAccount::factory()->telegram()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'platform_user_id' => '-1009999999999',
|
||||
]);
|
||||
|
||||
$code = TelegramConnectCode::issue($this->workspace->id, now()->addMinutes(15), $account->id);
|
||||
|
||||
$this->withHeader('X-Telegram-Bot-Api-Secret-Token', 'shh-secret')
|
||||
->postJson(route('telegram.webhook'), telegramUpdate($code))
|
||||
->assertNoContent();
|
||||
|
||||
Event::assertDispatched(
|
||||
TelegramConnectFailed::class,
|
||||
fn (TelegramConnectFailed $event): bool => $event->reason === 'wrong_chat'
|
||||
);
|
||||
|
||||
expect($this->workspace->socialAccounts()->count())->toBe(1)
|
||||
->and($account->fresh()->platform_user_id)->toBe('-1009999999999')
|
||||
->and(Cache::has("telegram:connect:{$code}"))->toBeFalse();
|
||||
});
|
||||
|
||||
it('lets the user retry in the right chat with the same code', function () {
|
||||
Http::fake();
|
||||
config(['trypost.allow_multiple_social_accounts' => false]);
|
||||
|
||||
$account = SocialAccount::factory()->telegram()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'platform_user_id' => '-1001234567890',
|
||||
'username' => 'stale',
|
||||
]);
|
||||
|
||||
$code = TelegramConnectCode::issue($this->workspace->id, now()->addMinutes(15), $account->id);
|
||||
|
||||
$this->withHeader('X-Telegram-Bot-Api-Secret-Token', 'shh-secret')
|
||||
->postJson(route('telegram.webhook'), telegramUpdate($code, ['id' => -1009999999999]))
|
||||
->assertNoContent();
|
||||
|
||||
$this->withHeader('X-Telegram-Bot-Api-Secret-Token', 'shh-secret')
|
||||
->postJson(route('telegram.webhook'), telegramUpdate($code))
|
||||
->assertNoContent();
|
||||
|
||||
expect($account->fresh()->platform_user_id)->toBe('-1001234567890')
|
||||
->and($this->workspace->socialAccounts()->count())->toBe(1);
|
||||
});
|
||||
|
||||
it('reconnects the card when its own chat posts the code', function () {
|
||||
Http::fake();
|
||||
config(['trypost.allow_multiple_social_accounts' => false]);
|
||||
|
||||
$account = SocialAccount::factory()->telegram()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'platform_user_id' => '-1001234567890',
|
||||
'username' => 'stale',
|
||||
]);
|
||||
|
||||
$code = TelegramConnectCode::issue($this->workspace->id, now()->addMinutes(15), $account->id);
|
||||
|
||||
$this->withHeader('X-Telegram-Bot-Api-Secret-Token', 'shh-secret')
|
||||
->postJson(route('telegram.webhook'), telegramUpdate($code))
|
||||
->assertNoContent();
|
||||
|
||||
expect($this->workspace->socialAccounts()->count())->toBe(1)
|
||||
->and($account->fresh()->platform_user_id)->toBe('-1001234567890')
|
||||
->and($account->fresh()->id)->toBe($account->id);
|
||||
});
|
||||
|
||||
it('consumes the code once so it cannot be replayed for another chat', function () {
|
||||
Http::fake();
|
||||
$code = TelegramConnectCode::issue($this->workspace->id, now()->addMinutes(15));
|
||||
|
|
@ -432,3 +520,28 @@ function telegramUpdate(string $code, array $chat = []): array
|
|||
&& str_contains($request['url'], 'telegram/webhook');
|
||||
});
|
||||
});
|
||||
|
||||
it('reports a busy connect instead of letting the webhook fail', function () {
|
||||
Http::fake();
|
||||
Event::fake([TelegramConnectFailed::class]);
|
||||
|
||||
$code = TelegramConnectCode::issue($this->workspace->id, now()->addMinutes(15));
|
||||
$lock = Cache::lock("social_connect:{$this->workspace->id}:telegram", 10);
|
||||
|
||||
expect($lock->get())->toBeTrue();
|
||||
|
||||
try {
|
||||
$this->withHeader('X-Telegram-Bot-Api-Secret-Token', 'shh-secret')
|
||||
->postJson(route('telegram.webhook'), telegramUpdate($code))
|
||||
->assertNoContent();
|
||||
} finally {
|
||||
$lock->release();
|
||||
}
|
||||
|
||||
Event::assertDispatched(
|
||||
TelegramConnectFailed::class,
|
||||
fn (TelegramConnectFailed $event): bool => $event->reason === 'busy'
|
||||
);
|
||||
|
||||
expect($this->workspace->socialAccounts()->count())->toBe(0);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -106,8 +106,8 @@
|
|||
$response->assertInertia(fn (AssertableInertia $page) => $page->where('message', 'Session expired. Please try again.'));
|
||||
});
|
||||
|
||||
test('user can connect multiple threads accounts in self-hosted mode', function () {
|
||||
config()->set('trypost.self_hosted', true);
|
||||
test('user can connect multiple threads accounts when multiple social accounts are allowed', function () {
|
||||
config()->set('trypost.allow_multiple_social_accounts', true);
|
||||
|
||||
SocialAccount::factory()->threads()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
|
|
@ -174,7 +174,7 @@
|
|||
});
|
||||
|
||||
test('threads callback shows network_taken when the network is already connected', function () {
|
||||
config()->set('trypost.self_hosted', false);
|
||||
config()->set('trypost.allow_multiple_social_accounts', false);
|
||||
|
||||
SocialAccount::factory()->threads()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
|
|
@ -289,3 +289,102 @@
|
|||
expect($account->token_expires_at)->not->toBeNull();
|
||||
expect($account->token_expires_at->isAfter(now()->addDays(59)))->toBeTrue();
|
||||
});
|
||||
|
||||
test('threads callback reconnects the original card', function () {
|
||||
$account = SocialAccount::factory()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'platform' => Platform::Threads,
|
||||
'platform_user_id' => '123456789',
|
||||
'username' => 'old',
|
||||
'access_token' => 'expired-token',
|
||||
'status' => Status::TokenExpired,
|
||||
]);
|
||||
|
||||
$state = bin2hex(random_bytes(16));
|
||||
|
||||
session([
|
||||
'social_connect_workspace' => $this->workspace->id,
|
||||
'social_reconnect_id' => $account->id,
|
||||
'threads_oauth_state' => $state,
|
||||
]);
|
||||
|
||||
$authApi = config('trypost.platforms.threads.auth_api');
|
||||
$graphApi = config('trypost.platforms.threads.graph_api');
|
||||
|
||||
Http::fake([
|
||||
"{$authApi}/oauth/access_token" => Http::response([
|
||||
'access_token' => 'short-lived-token',
|
||||
'user_id' => '123456789',
|
||||
], 200),
|
||||
"{$authApi}/access_token*" => Http::response([
|
||||
'access_token' => 'long-lived-token',
|
||||
'expires_in' => 5184000,
|
||||
], 200),
|
||||
"{$graphApi}/123456789*" => Http::response([
|
||||
'id' => '123456789',
|
||||
'username' => 'testuser',
|
||||
'name' => 'Test User',
|
||||
'threads_profile_picture_url' => null,
|
||||
], 200),
|
||||
]);
|
||||
|
||||
$this->actingAs($this->user)
|
||||
->get(route('app.social.threads.callback', ['code' => 'test-auth-code', 'state' => $state]))
|
||||
->assertOk()
|
||||
->assertInertia(fn (AssertableInertia $page) => $page
|
||||
->where('success', true)
|
||||
->where('message', __('accounts.popup_callback.reconnected'))
|
||||
);
|
||||
|
||||
expect($this->workspace->socialAccounts()->count())->toBe(1)
|
||||
->and($account->fresh()->username)->toBe('testuser')
|
||||
->and($account->fresh()->status)->toBe(Status::Connected);
|
||||
});
|
||||
|
||||
test('threads reconnect that authorizes another account says so instead of connecting', function () {
|
||||
$account = SocialAccount::factory()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'platform' => Platform::Threads,
|
||||
'platform_user_id' => '123456789',
|
||||
'username' => 'old',
|
||||
]);
|
||||
|
||||
$state = bin2hex(random_bytes(16));
|
||||
|
||||
session([
|
||||
'social_connect_workspace' => $this->workspace->id,
|
||||
'social_reconnect_id' => $account->id,
|
||||
'threads_oauth_state' => $state,
|
||||
]);
|
||||
|
||||
$authApi = config('trypost.platforms.threads.auth_api');
|
||||
$graphApi = config('trypost.platforms.threads.graph_api');
|
||||
|
||||
Http::fake([
|
||||
"{$authApi}/oauth/access_token" => Http::response([
|
||||
'access_token' => 'short-lived-token',
|
||||
'user_id' => '999999999',
|
||||
], 200),
|
||||
"{$authApi}/access_token*" => Http::response([
|
||||
'access_token' => 'long-lived-token',
|
||||
'expires_in' => 5184000,
|
||||
], 200),
|
||||
"{$graphApi}/999999999*" => Http::response([
|
||||
'id' => '999999999',
|
||||
'username' => 'someone-else',
|
||||
'name' => 'Someone Else',
|
||||
'threads_profile_picture_url' => null,
|
||||
], 200),
|
||||
]);
|
||||
|
||||
$this->actingAs($this->user)
|
||||
->get(route('app.social.threads.callback', ['code' => 'test-auth-code', 'state' => $state]))
|
||||
->assertOk()
|
||||
->assertInertia(fn (AssertableInertia $page) => $page
|
||||
->where('success', false)
|
||||
->where('message', __('accounts.popup_callback.wrong_account'))
|
||||
);
|
||||
|
||||
expect($this->workspace->socialAccounts()->count())->toBe(1)
|
||||
->and($account->fresh()->platform_user_id)->toBe('123456789');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -87,8 +87,8 @@
|
|||
$response->assertInertia(fn (AssertableInertia $page) => $page->where('message', 'Session expired. Please try again.'));
|
||||
});
|
||||
|
||||
test('user can connect multiple tiktok accounts in self-hosted mode', function () {
|
||||
config()->set('trypost.self_hosted', true);
|
||||
test('user can connect multiple tiktok accounts when multiple social accounts are allowed', function () {
|
||||
config()->set('trypost.allow_multiple_social_accounts', true);
|
||||
|
||||
SocialAccount::factory()->tiktok()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
|
|
@ -125,6 +125,45 @@
|
|||
expect($this->workspace->socialAccounts()->where('platform', Platform::TikTok)->count())->toBe(2);
|
||||
});
|
||||
|
||||
test('tiktok callback shows network_taken when the network is already connected', function () {
|
||||
config()->set('trypost.allow_multiple_social_accounts', false);
|
||||
|
||||
SocialAccount::factory()->tiktok()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'platform_user_id' => 'tiktok123',
|
||||
]);
|
||||
|
||||
session([
|
||||
'social_connect_workspace' => $this->workspace->id,
|
||||
]);
|
||||
|
||||
$socialiteUser = Mockery::mock(SocialiteUser::class);
|
||||
$socialiteUser->shouldReceive('getId')->andReturn('tiktok456');
|
||||
$socialiteUser->shouldReceive('getNickname')->andReturn('anothertiktoker');
|
||||
$socialiteUser->shouldReceive('getName')->andReturn('Another TikTok User');
|
||||
$socialiteUser->shouldReceive('getAvatar')->andReturn(null);
|
||||
$socialiteUser->token = 'new-access-token';
|
||||
$socialiteUser->refreshToken = 'new-refresh-token';
|
||||
$socialiteUser->expiresIn = 86400;
|
||||
$socialiteUser->approvedScopes = ['user.info.basic', 'user.info.profile', 'video.publish'];
|
||||
|
||||
$socialiteMock = Mockery::mock();
|
||||
$socialiteMock->shouldReceive('scopes')->andReturn($socialiteMock);
|
||||
$socialiteMock->shouldReceive('user')->andReturn($socialiteUser);
|
||||
|
||||
Socialite::shouldReceive('driver')
|
||||
->with('tiktok')
|
||||
->andReturn($socialiteMock);
|
||||
|
||||
$response = $this->actingAs($this->user)->get(route('app.social.tiktok.callback'));
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertInertia(fn (AssertableInertia $page) => $page->where('success', false));
|
||||
$response->assertInertia(fn (AssertableInertia $page) => $page->where('message', __('accounts.popup_callback.network_taken')));
|
||||
|
||||
expect($this->workspace->socialAccounts()->where('platform', Platform::TikTok)->count())->toBe(1);
|
||||
});
|
||||
|
||||
test('tiktok callback handles oauth errors gracefully', function () {
|
||||
session([
|
||||
'social_connect_workspace' => $this->workspace->id,
|
||||
|
|
@ -144,3 +183,112 @@
|
|||
$response->assertInertia(fn (AssertableInertia $page) => $page->where('success', false));
|
||||
$response->assertInertia(fn (AssertableInertia $page) => $page->where('message', 'Error connecting account. Please try again.'));
|
||||
});
|
||||
|
||||
test('tiktok connect carries a reconnect id into the session', function () {
|
||||
$account = SocialAccount::factory()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'platform' => Platform::TikTok,
|
||||
'platform_user_id' => 'tiktok123',
|
||||
]);
|
||||
|
||||
$driverMock = Mockery::mock();
|
||||
$driverMock->shouldReceive('scopes')->andReturnSelf();
|
||||
$driverMock->shouldReceive('redirect')->andReturn(Mockery::mock([
|
||||
'getTargetUrl' => 'https://www.tiktok.com/v2/auth/authorize?test=1',
|
||||
]));
|
||||
|
||||
Socialite::shouldReceive('driver')->with('tiktok')->andReturn($driverMock);
|
||||
|
||||
$this->actingAs($this->user)
|
||||
->withHeader('X-Inertia', 'true')
|
||||
->get(route('app.social.tiktok.connect', ['reconnect' => $account->id]))
|
||||
->assertStatus(409);
|
||||
|
||||
expect(session('social_reconnect_id'))->toBe($account->id);
|
||||
});
|
||||
|
||||
test('tiktok callback reconnects the original card', function () {
|
||||
$account = SocialAccount::factory()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'platform' => Platform::TikTok,
|
||||
'platform_user_id' => 'tiktok123',
|
||||
'username' => 'old',
|
||||
'access_token' => 'expired-token',
|
||||
'status' => Status::TokenExpired,
|
||||
]);
|
||||
|
||||
session([
|
||||
'social_connect_workspace' => $this->workspace->id,
|
||||
'social_reconnect_id' => $account->id,
|
||||
]);
|
||||
|
||||
$socialiteUser = Mockery::mock(SocialiteUser::class);
|
||||
$socialiteUser->shouldReceive('getId')->andReturn('tiktok123');
|
||||
$socialiteUser->shouldReceive('getNickname')->andReturn('tiktoker');
|
||||
$socialiteUser->shouldReceive('getName')->andReturn('TikTok User');
|
||||
$socialiteUser->shouldReceive('getAvatar')->andReturn(null);
|
||||
$socialiteUser->token = 'fresh-access-token';
|
||||
$socialiteUser->refreshToken = 'fresh-refresh-token';
|
||||
$socialiteUser->expiresIn = 86400;
|
||||
$socialiteUser->approvedScopes = ['user.info.basic', 'user.info.profile', 'video.publish'];
|
||||
|
||||
$socialiteMock = Mockery::mock();
|
||||
$socialiteMock->shouldReceive('scopes')->andReturn($socialiteMock);
|
||||
$socialiteMock->shouldReceive('user')->andReturn($socialiteUser);
|
||||
|
||||
Socialite::shouldReceive('driver')->with('tiktok')->andReturn($socialiteMock);
|
||||
|
||||
$this->actingAs($this->user)
|
||||
->get(route('app.social.tiktok.callback'))
|
||||
->assertOk()
|
||||
->assertInertia(fn (AssertableInertia $page) => $page
|
||||
->where('success', true)
|
||||
->where('message', __('accounts.popup_callback.reconnected'))
|
||||
);
|
||||
|
||||
expect($this->workspace->socialAccounts()->count())->toBe(1)
|
||||
->and($account->fresh()->access_token)->toBe('fresh-access-token')
|
||||
->and($account->fresh()->username)->toBe('tiktoker')
|
||||
->and($account->fresh()->status)->toBe(Status::Connected);
|
||||
});
|
||||
|
||||
test('tiktok reconnect that authorizes another account says so instead of connecting', function () {
|
||||
$account = SocialAccount::factory()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'platform' => Platform::TikTok,
|
||||
'platform_user_id' => 'tiktok123',
|
||||
'username' => 'old',
|
||||
]);
|
||||
|
||||
session([
|
||||
'social_connect_workspace' => $this->workspace->id,
|
||||
'social_reconnect_id' => $account->id,
|
||||
]);
|
||||
|
||||
$socialiteUser = Mockery::mock(SocialiteUser::class);
|
||||
$socialiteUser->shouldReceive('getId')->andReturn('tiktok999');
|
||||
$socialiteUser->shouldReceive('getNickname')->andReturn('someone-else');
|
||||
$socialiteUser->shouldReceive('getName')->andReturn('Someone Else');
|
||||
$socialiteUser->shouldReceive('getAvatar')->andReturn(null);
|
||||
$socialiteUser->token = 'other-access-token';
|
||||
$socialiteUser->refreshToken = 'other-refresh-token';
|
||||
$socialiteUser->expiresIn = 86400;
|
||||
$socialiteUser->approvedScopes = ['user.info.basic'];
|
||||
|
||||
$socialiteMock = Mockery::mock();
|
||||
$socialiteMock->shouldReceive('scopes')->andReturn($socialiteMock);
|
||||
$socialiteMock->shouldReceive('user')->andReturn($socialiteUser);
|
||||
|
||||
Socialite::shouldReceive('driver')->with('tiktok')->andReturn($socialiteMock);
|
||||
|
||||
$this->actingAs($this->user)
|
||||
->get(route('app.social.tiktok.callback'))
|
||||
->assertOk()
|
||||
->assertInertia(fn (AssertableInertia $page) => $page
|
||||
->where('success', false)
|
||||
->where('message', __('accounts.popup_callback.wrong_account'))
|
||||
);
|
||||
|
||||
expect($this->workspace->socialAccounts()->count())->toBe(1)
|
||||
->and($account->fresh()->platform_user_id)->toBe('tiktok123');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -85,8 +85,8 @@
|
|||
$response->assertInertia(fn (AssertableInertia $page) => $page->where('message', 'Session expired. Please try again.'));
|
||||
});
|
||||
|
||||
test('user can connect multiple x accounts in self-hosted mode', function () {
|
||||
config()->set('trypost.self_hosted', true);
|
||||
test('user can connect multiple x accounts when multiple social accounts are allowed', function () {
|
||||
config()->set('trypost.allow_multiple_social_accounts', true);
|
||||
|
||||
SocialAccount::factory()->x()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
|
|
@ -121,6 +121,82 @@
|
|||
expect($this->workspace->socialAccounts()->where('platform', Platform::X)->count())->toBe(2);
|
||||
});
|
||||
|
||||
test('x callback shows network_taken when the network is already connected', function () {
|
||||
config()->set('trypost.allow_multiple_social_accounts', false);
|
||||
|
||||
SocialAccount::factory()->x()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'platform_user_id' => '123456789',
|
||||
]);
|
||||
|
||||
session(['social_connect_workspace' => $this->workspace->id]);
|
||||
|
||||
$socialiteUser = Mockery::mock(SocialiteUser::class);
|
||||
$socialiteUser->shouldReceive('getId')->andReturn('987654321');
|
||||
$socialiteUser->shouldReceive('getNickname')->andReturn('anotheruser');
|
||||
$socialiteUser->shouldReceive('getName')->andReturn('Another User');
|
||||
$socialiteUser->shouldReceive('getAvatar')->andReturn(null);
|
||||
$socialiteUser->token = 'new-access-token';
|
||||
$socialiteUser->refreshToken = 'new-refresh-token';
|
||||
$socialiteUser->expiresIn = 7200;
|
||||
$socialiteUser->approvedScopes = ['tweet.read', 'tweet.write'];
|
||||
|
||||
Socialite::shouldReceive('driver')
|
||||
->with('x')
|
||||
->andReturn(Mockery::mock([
|
||||
'user' => $socialiteUser,
|
||||
]));
|
||||
|
||||
$response = $this->actingAs($this->user)->get(route('app.social.x.callback'));
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertInertia(fn (AssertableInertia $page) => $page->component('accounts/PopupCallback'));
|
||||
$response->assertInertia(fn (AssertableInertia $page) => $page->where('success', false));
|
||||
$response->assertInertia(fn (AssertableInertia $page) => $page->where('message', __('accounts.popup_callback.network_taken')));
|
||||
|
||||
expect($this->workspace->socialAccounts()->where('platform', Platform::X)->count())->toBe(1);
|
||||
});
|
||||
|
||||
test('x callback reconnects the original card', function () {
|
||||
$account = SocialAccount::factory()->x()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'platform_user_id' => '123456789',
|
||||
'username' => 'old',
|
||||
'access_token' => 'expired-token',
|
||||
]);
|
||||
|
||||
session([
|
||||
'social_connect_workspace' => $this->workspace->id,
|
||||
'social_reconnect_id' => $account->id,
|
||||
]);
|
||||
|
||||
$socialiteUser = Mockery::mock(SocialiteUser::class);
|
||||
$socialiteUser->shouldReceive('getId')->andReturn('123456789');
|
||||
$socialiteUser->shouldReceive('getNickname')->andReturn('testuser');
|
||||
$socialiteUser->shouldReceive('getName')->andReturn('Test User');
|
||||
$socialiteUser->shouldReceive('getAvatar')->andReturn(null);
|
||||
$socialiteUser->token = 'fresh-access-token';
|
||||
$socialiteUser->refreshToken = 'fresh-refresh-token';
|
||||
$socialiteUser->expiresIn = 7200;
|
||||
$socialiteUser->approvedScopes = ['tweet.read', 'tweet.write', 'users.read'];
|
||||
|
||||
Socialite::shouldReceive('driver')
|
||||
->with('x')
|
||||
->andReturn(Mockery::mock(['user' => $socialiteUser]));
|
||||
|
||||
$this->actingAs($this->user)
|
||||
->get(route('app.social.x.callback'))
|
||||
->assertOk()
|
||||
->assertInertia(fn (AssertableInertia $page) => $page
|
||||
->where('success', true)
|
||||
->where('message', __('accounts.popup_callback.reconnected'))
|
||||
);
|
||||
|
||||
expect($this->workspace->socialAccounts()->where('platform', Platform::X)->count())->toBe(1)
|
||||
->and($account->fresh()->access_token)->toBe('fresh-access-token')
|
||||
->and($account->fresh()->username)->toBe('testuser');
|
||||
});
|
||||
|
||||
test('x callback handles oauth errors gracefully', function () {
|
||||
session([
|
||||
'social_connect_workspace' => $this->workspace->id,
|
||||
|
|
@ -139,3 +215,40 @@
|
|||
$response->assertInertia(fn (AssertableInertia $page) => $page->where('success', false));
|
||||
$response->assertInertia(fn (AssertableInertia $page) => $page->where('message', 'Error connecting account. Please try again.'));
|
||||
});
|
||||
|
||||
test('x reconnect that authorizes another account says so instead of network_taken', function () {
|
||||
$account = SocialAccount::factory()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'platform' => Platform::X,
|
||||
'platform_user_id' => 'x-brand',
|
||||
'username' => 'brand',
|
||||
]);
|
||||
|
||||
session([
|
||||
'social_connect_workspace' => $this->workspace->id,
|
||||
'social_reconnect_id' => $account->id,
|
||||
]);
|
||||
|
||||
$socialiteUser = Mockery::mock(SocialiteUser::class);
|
||||
$socialiteUser->shouldReceive('getId')->andReturn('x-personal');
|
||||
$socialiteUser->shouldReceive('getNickname')->andReturn('personal');
|
||||
$socialiteUser->shouldReceive('getName')->andReturn('Personal');
|
||||
$socialiteUser->shouldReceive('getAvatar')->andReturn(null);
|
||||
$socialiteUser->token = 'personal-token';
|
||||
$socialiteUser->refreshToken = 'personal-refresh';
|
||||
$socialiteUser->expiresIn = 3600;
|
||||
|
||||
Socialite::shouldReceive('driver')
|
||||
->with('x')
|
||||
->andReturn(Mockery::mock(['user' => $socialiteUser]));
|
||||
|
||||
$this->actingAs($this->user)
|
||||
->get(route('app.social.x.callback'))
|
||||
->assertOk()
|
||||
->assertInertia(fn (AssertableInertia $page) => $page
|
||||
->where('success', false)
|
||||
->where('message', __('accounts.popup_callback.wrong_account'))
|
||||
);
|
||||
|
||||
expect($account->fresh()->platform_user_id)->toBe('x-brand');
|
||||
});
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue