Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
49 commits
Select commit Hold shift + click to select a range
1bf04af
feat: expose self-hosted mode to the accounts UI
HafizMMoaz Aug 17, 2026
18939ed
feat: allow connecting multiple accounts per network when self-hosted
HafizMMoaz Aug 17, 2026
d3cbd0c
test: cover the selfHosted prop on accounts and onboarding pages
HafizMMoaz Aug 17, 2026
10ac46d
style: apply prettier formatting
HafizMMoaz Aug 17, 2026
790497b
Merge branch 'main' into feat/issue-197-multi-linkedin
paulocastellano Aug 21, 2026
f82f747
refactor: read selfHosted from shared Inertia props
paulocastellano Aug 21, 2026
af6a250
feat: gate multiple social accounts with a dedicated env
paulocastellano Aug 21, 2026
493612a
fix: tighten multiple-account gates after review
paulocastellano Aug 21, 2026
31e9d2c
fix: bind reconnect to the card and unique social identity
paulocastellano Aug 21, 2026
ea27fab
refactor: build the OAuth URL before opening the popup
paulocastellano Aug 21, 2026
2bb134b
refactor: drop dead social-account guards and slim the connect grid
paulocastellano Aug 21, 2026
0eb2482
refactor: scope social reconnect to the current network
paulocastellano Aug 21, 2026
c529476
refactor: slim connectable-identity filtering
paulocastellano Aug 21, 2026
6779d3a
refactor: slim social identity persist helpers
paulocastellano Aug 21, 2026
0ee05d9
fix: keep reconnect updates on the original social card
paulocastellano Aug 21, 2026
a458aed
test: run the suite with multiple social accounts enabled
paulocastellano Aug 21, 2026
d0fbab5
fix: align the multi-account fallback with the self-hosted default
paulocastellano Aug 21, 2026
d1c70e1
fix: collapse duplicate identities before adding the unique index
paulocastellano Aug 21, 2026
7c57a99
fix: refuse a reconnect that authorized a different identity
paulocastellano Aug 21, 2026
2dd33cf
fix: filter connectable identities on every picker step
paulocastellano Aug 21, 2026
9f73802
refactor: type the connect cards and drop the dead accounts grid
paulocastellano Aug 21, 2026
f5df2d3
test: keep the suite on the cloud one-account-per-network default
paulocastellano Aug 21, 2026
b3460f5
fix: connect standalone instagram instead of reopening the picker
paulocastellano Aug 21, 2026
a57c569
fix: reject a telegram reconnect before burning the connect code
paulocastellano Aug 21, 2026
3ea0dc8
fix: leave one target per post when merging duplicate accounts
paulocastellano Aug 21, 2026
14e0560
refactor: collapse the repeated connect-flow boilerplate
paulocastellano Aug 21, 2026
2c2da11
fix: keep an expired connect popup out of the error log
paulocastellano Aug 21, 2026
077a9bc
fix: stop telling users to disconnect an account that is not the problem
paulocastellano Aug 21, 2026
b45d48e
refactor: filter linkedin identities with the shared helper
paulocastellano Aug 21, 2026
9feca3a
fix: keep the linkedin picker's own empty state
paulocastellano Aug 21, 2026
72f5b10
fix: keep the enabled row when collapsing duplicate post targets
paulocastellano Aug 21, 2026
660674b
fix: stop the migration destroying publish history and automations
paulocastellano Aug 21, 2026
68de539
fix: make the identity merge auditable and stop a second delivery
paulocastellano Aug 21, 2026
a29e5f9
test: rehearse the identity merge against a messy database
paulocastellano Aug 21, 2026
b064d1e
fix: stop the youtube picker refetching itself into a cleared session
paulocastellano Aug 21, 2026
7a425e1
refactor: drop the unreachable youtube channel picker
paulocastellano Aug 21, 2026
e322cdc
fix: serialize connects so two popups cannot seat one network twice
paulocastellano Aug 21, 2026
feff492
fix: handle a busy connect lock on the telegram path
paulocastellano Aug 21, 2026
cceb501
fix: stop the instagram login seating an account already held via fac…
paulocastellano Aug 23, 2026
71a019d
fix: name the real reason when a linkedin profile reconnect switches …
paulocastellano Aug 23, 2026
d77655d
fix: surface the busy telegram connect instead of a generic failure
paulocastellano Aug 23, 2026
690529c
test: cover reconnect on every flow that gained it
paulocastellano Aug 23, 2026
0b6e65a
fix: repair what a reconnect leaves behind when it cannot proceed cle…
paulocastellano Aug 23, 2026
313097a
refactor: resolve the linkedin reconnect card once per select
paulocastellano Aug 23, 2026
917d65d
test: render the grid's multi-account branch
paulocastellano Aug 23, 2026
9801e19
test: pin why the linkedin select guards exist
paulocastellano Aug 23, 2026
ba45eee
fix: carry retrying targets through a variant move, atomically
paulocastellano Aug 23, 2026
c553d7a
chore: drop the dusk selectors nothing reads
paulocastellano Aug 24, 2026
fb4f86e
chore: drop the last one-account-per-network helper
paulocastellano Aug 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .env.ci
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
28 changes: 22 additions & 6 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down Expand Up @@ -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

Expand Down
83 changes: 47 additions & 36 deletions app/Actions/SocialAccount/ConnectTelegramChannel.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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);

Expand Down Expand Up @@ -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();
}
}
3 changes: 1 addition & 2 deletions app/Enums/SocialAccount/Platform.php
Original file line number Diff line number Diff line change
Expand Up @@ -431,7 +431,7 @@ public static function instagramConnectMethods(): array
* Instagram includes `connect_methods` so the connect dialog only lists
* OAuth entry points that are actually enabled (self-hosters may disable one).
*
* @return list<array{value: string, label: string, color: string, network: string, connect_methods?: list<string>}>
* @return list<array{value: string, label: string, network: string, connect_methods?: list<string>}>
*/
public static function connectableOptions(): array
{
Expand All @@ -442,7 +442,6 @@ public static function connectableOptions(): array
$option = [
'value' => $platform->value,
'label' => $platform->label(),
'color' => $platform->color(),
'network' => $platform->network(),
];

Expand Down
43 changes: 43 additions & 0 deletions app/Exceptions/SocialAccount/ConnectPopupException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
<?php

declare(strict_types=1);

namespace App\Exceptions\SocialAccount;

use App\Enums\SocialAccount\Platform;
use Illuminate\Contracts\Debug\ShouldntReport;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
use RuntimeException;

/**
* Abort a social connect flow and close the popup with a reason.
*
* Rendering lives on the exception so the session and permission guards that
* open every connect action stay a single line instead of six.
*
* An expired popup session is a normal outcome, not an incident, so this never
* reaches the error log.
*/
class ConnectPopupException extends RuntimeException implements ShouldntReport
{
public function __construct(
public readonly string $messageKey,
public readonly ?Platform $platform = null,
) {
parent::__construct("Social connect aborted: {$messageKey}");
}

public function render(Request $request): Response
{
session()->forget(['social_connect_workspace', 'social_reconnect_id']);

return Inertia::render('accounts/PopupCallback', [
'success' => false,
'message' => __("accounts.popup_callback.{$this->messageKey}"),
'platform' => $this->platform?->value,
'onboardingProgress' => false,
]);
}
}
26 changes: 24 additions & 2 deletions app/Exceptions/SocialAccount/NetworkAlreadyConnectedException.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.");
}
}
19 changes: 13 additions & 6 deletions app/Http/Controllers/Auth/BlueskyController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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() ?? [],
]);
Expand Down Expand Up @@ -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')),
Expand All @@ -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(),
Expand Down
2 changes: 1 addition & 1 deletion app/Http/Controllers/Auth/DiscordController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
Loading