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