feat: redesign accounts page with multi-account support
- New card-based layout showing connected accounts with avatar, platform badge, brand label, and connection date - "Add Social" button opens dialog with platform grid for connecting - Removed unique constraint on workspace_id+platform to allow multiple accounts per platform - Removed "already connected" checks from all 13 OAuth controllers - Updated tests to verify multi-account connection works
This commit is contained in:
parent
3a98de0482
commit
8caa3d55c7
33 changed files with 430 additions and 857 deletions
|
|
@ -87,18 +87,10 @@ public function store(Request $request): View|RedirectResponse
|
|||
|
||||
$profile = $profileResponse->successful() ? $profileResponse->json() : [];
|
||||
|
||||
// Check existing
|
||||
$existingAccount = $workspace->socialAccounts()
|
||||
->where('platform', $this->platform->value)
|
||||
->first();
|
||||
|
||||
if ($existingAccount && ! $existingAccount->isDisconnected()) {
|
||||
return back()->withErrors(['identifier' => 'Bluesky is already connected.']);
|
||||
}
|
||||
|
||||
$avatarPath = data_get($profile, 'avatar') ? uploadFromUrl(data_get($profile, 'avatar')) : null;
|
||||
|
||||
$accountData = [
|
||||
// Create new account
|
||||
$workspace->socialAccounts()->create([
|
||||
'platform' => $this->platform->value,
|
||||
'platform_user_id' => data_get($data, 'did'),
|
||||
'username' => data_get($data, 'handle'),
|
||||
|
|
@ -107,22 +99,13 @@ public function store(Request $request): View|RedirectResponse
|
|||
'access_token' => data_get($data, 'accessJwt'),
|
||||
'refresh_token' => data_get($data, 'refreshJwt'),
|
||||
'token_expires_at' => now()->addHours(2),
|
||||
'status' => Status::Connected,
|
||||
'meta' => [
|
||||
'service' => $service,
|
||||
'identifier' => $request->identifier,
|
||||
'password' => encrypt($request->password),
|
||||
],
|
||||
];
|
||||
|
||||
if ($existingAccount) {
|
||||
$existingAccount->update($accountData);
|
||||
$existingAccount->markAsConnected();
|
||||
|
||||
return $this->popupCallback(true, 'Bluesky account reconnected!', $this->platform->value);
|
||||
}
|
||||
|
||||
$accountData['status'] = Status::Connected;
|
||||
$workspace->socialAccounts()->create($accountData);
|
||||
]);
|
||||
|
||||
return $this->popupCallback(true, 'Bluesky account connected!', $this->platform->value);
|
||||
} catch (\Exception $e) {
|
||||
|
|
|
|||
|
|
@ -44,20 +44,9 @@ public function connect(Request $request): Response|RedirectResponse
|
|||
$this->authorize('manageAccounts', $workspace);
|
||||
$this->ensureSocialAccountLimit($workspace);
|
||||
|
||||
$existingAccount = $workspace->socialAccounts()
|
||||
->where('platform', $this->platform->value)
|
||||
->first();
|
||||
|
||||
if ($existingAccount && ! $existingAccount->isDisconnected()) {
|
||||
session()->flash('flash.banner', __('accounts.flash.already_connected'));
|
||||
session()->flash('flash.bannerStyle', 'danger');
|
||||
|
||||
return back();
|
||||
}
|
||||
|
||||
session([
|
||||
'social_connect_workspace' => $workspace->id,
|
||||
'social_reconnect_id' => $existingAccount?->id,
|
||||
'social_reconnect_id' => null,
|
||||
'social_connect_onboarding' => $request->boolean('onboarding'),
|
||||
]);
|
||||
|
||||
|
|
@ -84,14 +73,6 @@ public function callback(Request $request): View|RedirectResponse
|
|||
return $this->popupCallback(false, 'Workspace not found.', $this->platform->value);
|
||||
}
|
||||
|
||||
$reconnectId = session('social_reconnect_id');
|
||||
$existingAccount = $reconnectId ? $workspace->socialAccounts()->find($reconnectId) : null;
|
||||
|
||||
// If account exists and is connected, don't allow duplicate
|
||||
if (! $existingAccount && $workspace->hasConnectedPlatform($this->platform->value)) {
|
||||
return $this->popupCallback(false, 'This platform is already connected.', $this->platform->value);
|
||||
}
|
||||
|
||||
try {
|
||||
$socialUser = Socialite::driver($this->driver)->usingGraphVersion('v25.0')->user();
|
||||
|
||||
|
|
@ -114,30 +95,6 @@ public function callback(Request $request): View|RedirectResponse
|
|||
$page = $pages[0];
|
||||
$avatarPath = uploadFromUrl(data_get($page, 'picture'));
|
||||
|
||||
if ($existingAccount) {
|
||||
// Reconnect existing account
|
||||
$existingAccount->update([
|
||||
'platform_user_id' => data_get($page, 'id'),
|
||||
'username' => data_get($page, 'username', null),
|
||||
'display_name' => data_get($page, 'name'),
|
||||
'avatar_url' => $avatarPath,
|
||||
'access_token' => data_get($page, 'access_token'),
|
||||
'refresh_token' => null,
|
||||
'token_expires_at' => null,
|
||||
'scopes' => $this->scopes,
|
||||
'meta' => [
|
||||
'page_id' => data_get($page, 'id'),
|
||||
'user_id' => $socialUser->getId(),
|
||||
'user_token' => $socialUser->token,
|
||||
],
|
||||
]);
|
||||
$existingAccount->markAsConnected();
|
||||
|
||||
session()->forget('social_reconnect_id');
|
||||
|
||||
return $this->popupCallback(true, 'Facebook Page reconnected!', $this->platform->value);
|
||||
}
|
||||
|
||||
// Create new account
|
||||
$workspace->socialAccounts()->create([
|
||||
'platform' => $this->platform->value,
|
||||
|
|
@ -157,8 +114,6 @@ public function callback(Request $request): View|RedirectResponse
|
|||
],
|
||||
]);
|
||||
|
||||
session()->forget('social_reconnect_id');
|
||||
|
||||
return $this->popupCallback(true, 'Facebook Page connected!', $this->platform->value);
|
||||
}
|
||||
|
||||
|
|
@ -168,7 +123,6 @@ public function callback(Request $request): View|RedirectResponse
|
|||
'user_token' => $socialUser->token,
|
||||
'user_id' => $socialUser->getId(),
|
||||
'pages' => $pages,
|
||||
'reconnect_id' => $reconnectId,
|
||||
],
|
||||
]);
|
||||
|
||||
|
|
|
|||
|
|
@ -40,20 +40,9 @@ public function connect(Request $request): Response|RedirectResponse
|
|||
$this->authorize('manageAccounts', $workspace);
|
||||
$this->ensureSocialAccountLimit($workspace);
|
||||
|
||||
$existingAccount = $workspace->socialAccounts()
|
||||
->where('platform', $this->platform->value)
|
||||
->first();
|
||||
|
||||
if ($existingAccount && ! $existingAccount->isDisconnected()) {
|
||||
session()->flash('flash.banner', __('accounts.flash.already_connected'));
|
||||
session()->flash('flash.bannerStyle', 'danger');
|
||||
|
||||
return back();
|
||||
}
|
||||
|
||||
session([
|
||||
'social_connect_workspace' => $workspace->id,
|
||||
'social_reconnect_id' => $existingAccount?->id,
|
||||
'social_reconnect_id' => null,
|
||||
'social_connect_onboarding' => $request->boolean('onboarding'),
|
||||
]);
|
||||
|
||||
|
|
@ -79,14 +68,6 @@ public function callback(Request $request): View
|
|||
return $this->popupCallback(false, 'Workspace not found.', $this->platform->value);
|
||||
}
|
||||
|
||||
$reconnectId = session('social_reconnect_id');
|
||||
$existingAccount = $reconnectId ? $workspace->socialAccounts()->find($reconnectId) : null;
|
||||
|
||||
// If account exists and is connected, don't allow duplicate
|
||||
if (! $existingAccount && $workspace->hasConnectedPlatform($this->platform->value)) {
|
||||
return $this->popupCallback(false, 'This platform is already connected.', $this->platform->value);
|
||||
}
|
||||
|
||||
try {
|
||||
$socialUser = Socialite::driver($this->driver)->user();
|
||||
|
||||
|
|
@ -97,28 +78,6 @@ public function callback(Request $request): View
|
|||
$expiresIn = $socialUser->expiresIn ?? 5184000; // 60 days in seconds
|
||||
$tokenExpiresAt = now()->addSeconds($expiresIn);
|
||||
|
||||
if ($existingAccount) {
|
||||
// Reconnect existing account
|
||||
$existingAccount->update([
|
||||
'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' => $tokenExpiresAt,
|
||||
'scopes' => $this->scopes,
|
||||
'meta' => [
|
||||
'account_type' => $socialUser->user['account_type'] ?? null,
|
||||
],
|
||||
]);
|
||||
$existingAccount->markAsConnected();
|
||||
|
||||
session()->forget('social_reconnect_id');
|
||||
|
||||
return $this->popupCallback(true, 'Instagram account reconnected!', $this->platform->value);
|
||||
}
|
||||
|
||||
// Create new account
|
||||
$workspace->socialAccounts()->create([
|
||||
'platform' => $this->platform->value,
|
||||
|
|
@ -136,8 +95,6 @@ public function callback(Request $request): View
|
|||
],
|
||||
]);
|
||||
|
||||
session()->forget('social_reconnect_id');
|
||||
|
||||
return $this->popupCallback(true, 'Instagram account connected!', $this->platform->value);
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Instagram OAuth Error', [
|
||||
|
|
|
|||
|
|
@ -46,13 +46,9 @@ public function connect(Request $request): Response|RedirectResponse
|
|||
$this->authorize('manageAccounts', $workspace);
|
||||
$this->ensureSocialAccountLimit($workspace);
|
||||
|
||||
$existingAccount = $workspace->socialAccounts()
|
||||
->where('platform', $this->platform->value)
|
||||
->first();
|
||||
|
||||
session([
|
||||
'social_connect_workspace' => $workspace->id,
|
||||
'social_reconnect_id' => $existingAccount?->id,
|
||||
'social_reconnect_id' => null,
|
||||
'social_connect_onboarding' => $request->boolean('onboarding'),
|
||||
]);
|
||||
|
||||
|
|
|
|||
|
|
@ -41,17 +41,6 @@ public function connect(Request $request): Response|RedirectResponse
|
|||
|
||||
$this->authorize('manageAccounts', $workspace);
|
||||
|
||||
$existingAccount = $workspace->socialAccounts()
|
||||
->where('platform', $this->platform->value)
|
||||
->first();
|
||||
|
||||
if ($existingAccount && ! $existingAccount->isDisconnected()) {
|
||||
session()->flash('flash.banner', __('accounts.flash.already_connected'));
|
||||
session()->flash('flash.bannerStyle', 'danger');
|
||||
|
||||
return back();
|
||||
}
|
||||
|
||||
return $this->redirectToProvider($request, $this->driver, $this->scopes);
|
||||
}
|
||||
|
||||
|
|
@ -71,14 +60,6 @@ public function callback(Request $request): View
|
|||
|
||||
try {
|
||||
$socialUser = Socialite::driver($this->driver)->user();
|
||||
$existingAccount = $workspace->socialAccounts()
|
||||
->where('platform', $this->platform->value)
|
||||
->first();
|
||||
|
||||
// If account exists and is connected, don't allow duplicate
|
||||
if ($existingAccount && ! $existingAccount->isDisconnected()) {
|
||||
return $this->popupCallback(false, 'This platform is already connected.', $this->platform->value);
|
||||
}
|
||||
|
||||
// Fetch vanityName from LinkedIn API (not available via OpenID)
|
||||
$username = $this->fetchVanityName($socialUser->token);
|
||||
|
|
@ -91,26 +72,6 @@ public function callback(Request $request): View
|
|||
|
||||
$avatarPath = uploadFromUrl($socialUser->getAvatar());
|
||||
|
||||
if ($existingAccount) {
|
||||
// Reconnect existing account
|
||||
$existingAccount->update([
|
||||
'platform_user_id' => $socialUser->getId(),
|
||||
'username' => $username,
|
||||
'display_name' => $socialUser->getName(),
|
||||
'avatar_url' => $avatarPath,
|
||||
'access_token' => $socialUser->token,
|
||||
'refresh_token' => $socialUser->refreshToken,
|
||||
'token_expires_at' => $socialUser->expiresIn ? now()->addSeconds($socialUser->expiresIn) : null,
|
||||
'scopes' => $socialUser->approvedScopes ?? null,
|
||||
]);
|
||||
$existingAccount->markAsConnected();
|
||||
|
||||
// Sync tokens to LinkedIn Page if it exists
|
||||
app(LinkedInTokenSynchronizer::class)->syncTokens($existingAccount);
|
||||
|
||||
return $this->popupCallback(true, 'LinkedIn account reconnected!', $this->platform->value);
|
||||
}
|
||||
|
||||
// Create new account
|
||||
$account = $workspace->socialAccounts()->create([
|
||||
'platform' => $this->platform->value,
|
||||
|
|
|
|||
|
|
@ -47,20 +47,9 @@ public function connect(Request $request): SymfonyResponse|RedirectResponse
|
|||
$this->authorize('manageAccounts', $workspace);
|
||||
$this->ensureSocialAccountLimit($workspace);
|
||||
|
||||
$existingAccount = $workspace->socialAccounts()
|
||||
->where('platform', $this->platform->value)
|
||||
->first();
|
||||
|
||||
if ($existingAccount && ! $existingAccount->isDisconnected()) {
|
||||
session()->flash('flash.banner', __('accounts.flash.already_connected'));
|
||||
session()->flash('flash.bannerStyle', 'danger');
|
||||
|
||||
return back();
|
||||
}
|
||||
|
||||
session([
|
||||
'social_connect_workspace' => $workspace->id,
|
||||
'linkedin_page_reconnect_id' => $existingAccount?->id,
|
||||
'linkedin_page_reconnect_id' => null,
|
||||
'social_connect_onboarding' => $request->boolean('onboarding'),
|
||||
]);
|
||||
|
||||
|
|
|
|||
|
|
@ -181,20 +181,10 @@ public function callback(Request $request): View
|
|||
|
||||
$profile = $profileResponse->json();
|
||||
|
||||
// Check existing
|
||||
$existingAccount = $workspace->socialAccounts()
|
||||
->where('platform', $this->platform->value)
|
||||
->first();
|
||||
|
||||
if ($existingAccount && ! $existingAccount->isDisconnected()) {
|
||||
$this->clearMastodonSession();
|
||||
|
||||
return $this->popupCallback(false, 'Mastodon is already connected.', $this->platform->value);
|
||||
}
|
||||
|
||||
$avatarPath = data_get($profile, 'avatar') ? uploadFromUrl(data_get($profile, 'avatar')) : null;
|
||||
|
||||
$accountData = [
|
||||
// Create new account
|
||||
$workspace->socialAccounts()->create([
|
||||
'platform' => $this->platform->value,
|
||||
'platform_user_id' => data_get($profile, 'id'),
|
||||
'username' => data_get($profile, 'acct'),
|
||||
|
|
@ -203,23 +193,13 @@ public function callback(Request $request): View
|
|||
'access_token' => $accessToken,
|
||||
'refresh_token' => null, // Mastodon tokens don't expire
|
||||
'token_expires_at' => null,
|
||||
'status' => Status::Connected,
|
||||
'meta' => [
|
||||
'instance' => $instance,
|
||||
'client_id' => $clientId,
|
||||
'client_secret' => $clientSecret,
|
||||
],
|
||||
];
|
||||
|
||||
if ($existingAccount) {
|
||||
$existingAccount->update($accountData);
|
||||
$existingAccount->markAsConnected();
|
||||
$this->clearMastodonSession();
|
||||
|
||||
return $this->popupCallback(true, 'Mastodon account reconnected!', $this->platform->value);
|
||||
}
|
||||
|
||||
$accountData['status'] = Status::Connected;
|
||||
$workspace->socialAccounts()->create($accountData);
|
||||
]);
|
||||
|
||||
$this->clearMastodonSession();
|
||||
|
||||
|
|
|
|||
|
|
@ -40,17 +40,6 @@ public function connect(Request $request): Response|RedirectResponse
|
|||
|
||||
$this->authorize('manageAccounts', $workspace);
|
||||
|
||||
$existingAccount = $workspace->socialAccounts()
|
||||
->where('platform', $this->platform->value)
|
||||
->first();
|
||||
|
||||
if ($existingAccount && ! $existingAccount->isDisconnected()) {
|
||||
session()->flash('flash.banner', __('accounts.flash.already_connected'));
|
||||
session()->flash('flash.bannerStyle', 'danger');
|
||||
|
||||
return back();
|
||||
}
|
||||
|
||||
return $this->redirectToProvider($request, $this->driver, $this->scopes);
|
||||
}
|
||||
|
||||
|
|
@ -70,14 +59,6 @@ public function callback(Request $request): View
|
|||
|
||||
try {
|
||||
$socialUser = Socialite::driver($this->driver)->user();
|
||||
$existingAccount = $workspace->socialAccounts()
|
||||
->where('platform', $this->platform->value)
|
||||
->first();
|
||||
|
||||
// If account exists and is connected, don't allow duplicate
|
||||
if ($existingAccount && ! $existingAccount->isDisconnected()) {
|
||||
return $this->popupCallback(false, 'This platform is already connected.', $this->platform->value);
|
||||
}
|
||||
|
||||
Log::info('Pinterest OAuth User Data', [
|
||||
'id' => $socialUser->getId(),
|
||||
|
|
@ -88,23 +69,6 @@ public function callback(Request $request): View
|
|||
|
||||
$avatarPath = uploadFromUrl($socialUser->getAvatar());
|
||||
|
||||
if ($existingAccount) {
|
||||
// Reconnect existing account
|
||||
$existingAccount->update([
|
||||
'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),
|
||||
'scopes' => $socialUser->approvedScopes ?? $this->scopes,
|
||||
]);
|
||||
$existingAccount->markAsConnected();
|
||||
|
||||
return $this->popupCallback(true, 'Pinterest account reconnected!', $this->platform->value);
|
||||
}
|
||||
|
||||
// Create new account
|
||||
$workspace->socialAccounts()->create([
|
||||
'platform' => $this->platform->value,
|
||||
|
|
|
|||
|
|
@ -55,22 +55,19 @@ public function index(Request $request): Response|RedirectResponse
|
|||
|
||||
$this->authorize('view', $workspace);
|
||||
|
||||
$connectedAccounts = $workspace->socialAccounts;
|
||||
$connectedAccounts = $workspace->socialAccounts()
|
||||
->with('brand')
|
||||
->get();
|
||||
|
||||
$platforms = collect(SocialPlatform::enabled())->map(function ($platform) use ($connectedAccounts) {
|
||||
$connected = $connectedAccounts->firstWhere('platform', $platform);
|
||||
|
||||
return [
|
||||
'value' => $platform->value,
|
||||
'label' => $platform->label(),
|
||||
'color' => $platform->color(),
|
||||
'connected' => $connected !== null,
|
||||
'account' => $connected,
|
||||
];
|
||||
})->values();
|
||||
$platforms = collect(SocialPlatform::enabled())->map(fn ($platform) => [
|
||||
'value' => $platform->value,
|
||||
'label' => $platform->label(),
|
||||
'color' => $platform->color(),
|
||||
])->values();
|
||||
|
||||
return Inertia::render('accounts/Index', [
|
||||
'workspace' => $workspace,
|
||||
'accounts' => $connectedAccounts,
|
||||
'platforms' => $platforms,
|
||||
]);
|
||||
}
|
||||
|
|
@ -160,34 +157,9 @@ protected function handleCallback(
|
|||
|
||||
try {
|
||||
$socialUser = Socialite::driver($driver)->user();
|
||||
$existingAccount = $workspace->socialAccounts()
|
||||
->where('platform', $platform->value)
|
||||
->first();
|
||||
|
||||
// If account exists and is connected, don't allow duplicate
|
||||
if ($existingAccount && ! $existingAccount->isDisconnected()) {
|
||||
return $this->popupCallback(false, 'This platform is already connected.', $platform->value);
|
||||
}
|
||||
|
||||
$avatarPath = uploadFromUrl($socialUser->getAvatar());
|
||||
|
||||
if ($existingAccount) {
|
||||
// Reconnect existing account
|
||||
$existingAccount->update([
|
||||
'platform_user_id' => $socialUser->getId(),
|
||||
'username' => $socialUser->getNickname(),
|
||||
'display_name' => $socialUser->getName(),
|
||||
'avatar_url' => $avatarPath,
|
||||
'access_token' => $socialUser->token,
|
||||
'refresh_token' => $socialUser->refreshToken,
|
||||
'token_expires_at' => $socialUser->expiresIn ? now()->addSeconds($socialUser->expiresIn) : null,
|
||||
'scopes' => $socialUser->approvedScopes ?? null,
|
||||
]);
|
||||
$existingAccount->markAsConnected();
|
||||
|
||||
return $this->popupCallback(true, 'Account reconnected!', $platform->value);
|
||||
}
|
||||
|
||||
// Create new account
|
||||
$workspace->socialAccounts()->create([
|
||||
'platform' => $platform->value,
|
||||
|
|
|
|||
|
|
@ -38,20 +38,9 @@ public function connect(Request $request): Response|RedirectResponse
|
|||
$this->authorize('manageAccounts', $workspace);
|
||||
$this->ensureSocialAccountLimit($workspace);
|
||||
|
||||
$existingAccount = $workspace->socialAccounts()
|
||||
->where('platform', $this->platform->value)
|
||||
->first();
|
||||
|
||||
if ($existingAccount && ! $existingAccount->isDisconnected()) {
|
||||
session()->flash('flash.banner', __('accounts.flash.already_connected'));
|
||||
session()->flash('flash.bannerStyle', 'danger');
|
||||
|
||||
return back();
|
||||
}
|
||||
|
||||
session([
|
||||
'social_connect_workspace' => $workspace->id,
|
||||
'social_reconnect_id' => $existingAccount?->id,
|
||||
'social_reconnect_id' => null,
|
||||
]);
|
||||
|
||||
$state = bin2hex(random_bytes(16));
|
||||
|
|
@ -93,16 +82,6 @@ public function callback(Request $request): View
|
|||
return $this->popupCallback(false, 'Workspace not found.', $this->platform->value);
|
||||
}
|
||||
|
||||
$reconnectId = session('social_reconnect_id');
|
||||
$existingAccount = $reconnectId ? $workspace->socialAccounts()->find($reconnectId) : null;
|
||||
|
||||
// If account exists and is connected, don't allow duplicate
|
||||
if (! $existingAccount && $workspace->hasConnectedPlatform($this->platform->value)) {
|
||||
session()->forget(['threads_oauth_state', 'social_reconnect_id']);
|
||||
|
||||
return $this->popupCallback(false, 'This platform is already connected.', $this->platform->value);
|
||||
}
|
||||
|
||||
try {
|
||||
// Exchange code for short-lived token
|
||||
$tokenResponse = Http::asForm()->post('https://graph.threads.net/oauth/access_token', [
|
||||
|
|
@ -157,25 +136,6 @@ public function callback(Request $request): View
|
|||
$profile = $profileResponse->json();
|
||||
$avatarPath = uploadFromUrl(data_get($profile, 'threads_profile_picture_url', null));
|
||||
|
||||
if ($existingAccount) {
|
||||
// Reconnect existing account
|
||||
$existingAccount->update([
|
||||
'platform_user_id' => data_get($profile, 'id'),
|
||||
'username' => data_get($profile, 'username'),
|
||||
'display_name' => data_get($profile, 'name', data_get($profile, 'username')),
|
||||
'avatar_url' => $avatarPath,
|
||||
'access_token' => $longLivedToken,
|
||||
'refresh_token' => $longLivedToken,
|
||||
'token_expires_at' => $expiresIn ? now()->addSeconds($expiresIn) : null,
|
||||
'scopes' => $this->scopes,
|
||||
]);
|
||||
$existingAccount->markAsConnected();
|
||||
|
||||
session()->forget(['threads_oauth_state', 'social_reconnect_id']);
|
||||
|
||||
return $this->popupCallback(true, 'Threads account reconnected!', $this->platform->value);
|
||||
}
|
||||
|
||||
// Create new account
|
||||
$workspace->socialAccounts()->create([
|
||||
'platform' => $this->platform->value,
|
||||
|
|
|
|||
|
|
@ -41,18 +41,7 @@ public function connect(Request $request): Response|RedirectResponse
|
|||
|
||||
$this->authorize('manageAccounts', $workspace);
|
||||
|
||||
$existingAccount = $workspace->socialAccounts()
|
||||
->where('platform', $this->platform->value)
|
||||
->first();
|
||||
|
||||
if ($existingAccount && ! $existingAccount->isDisconnected()) {
|
||||
session()->flash('flash.banner', __('accounts.flash.already_connected'));
|
||||
session()->flash('flash.bannerStyle', 'danger');
|
||||
|
||||
return back();
|
||||
}
|
||||
|
||||
session(['social_reconnect_id' => $existingAccount?->id]);
|
||||
session(['social_reconnect_id' => null]);
|
||||
|
||||
return $this->redirectToProvider($request, $this->driver, $this->scopes);
|
||||
}
|
||||
|
|
@ -71,14 +60,6 @@ public function callback(Request $request): View
|
|||
return $this->popupCallback(false, 'Workspace not found.', $this->platform->value);
|
||||
}
|
||||
|
||||
$reconnectId = session('social_reconnect_id');
|
||||
$existingAccount = $reconnectId ? $workspace->socialAccounts()->find($reconnectId) : null;
|
||||
|
||||
// If account exists and is connected, don't allow duplicate
|
||||
if (! $existingAccount && $workspace->hasConnectedPlatform($this->platform->value)) {
|
||||
return $this->popupCallback(false, 'This platform is already connected.', $this->platform->value);
|
||||
}
|
||||
|
||||
try {
|
||||
$socialUser = Socialite::driver($this->driver)
|
||||
->scopes($this->scopes)
|
||||
|
|
@ -94,25 +75,6 @@ public function callback(Request $request): View
|
|||
$username = $socialUser->getNickname();
|
||||
$avatarPath = uploadFromUrl($socialUser->getAvatar());
|
||||
|
||||
if ($existingAccount) {
|
||||
// Reconnect existing account
|
||||
$existingAccount->update([
|
||||
'platform_user_id' => $socialUser->getId(),
|
||||
'username' => $username,
|
||||
'display_name' => $socialUser->getName(),
|
||||
'avatar_url' => $avatarPath,
|
||||
'access_token' => $socialUser->token,
|
||||
'refresh_token' => $socialUser->refreshToken,
|
||||
'token_expires_at' => $socialUser->expiresIn ? now()->addSeconds($socialUser->expiresIn) : null,
|
||||
'scopes' => $socialUser->approvedScopes ?? null,
|
||||
]);
|
||||
$existingAccount->markAsConnected();
|
||||
|
||||
session()->forget('social_reconnect_id');
|
||||
|
||||
return $this->popupCallback(true, 'TikTok account reconnected!', $this->platform->value);
|
||||
}
|
||||
|
||||
// Create new account
|
||||
$workspace->socialAccounts()->create([
|
||||
'platform' => $this->platform->value,
|
||||
|
|
|
|||
|
|
@ -36,17 +36,6 @@ public function connect(Request $request): Response|RedirectResponse
|
|||
|
||||
$this->authorize('manageAccounts', $workspace);
|
||||
|
||||
$existingAccount = $workspace->socialAccounts()
|
||||
->where('platform', $this->platform->value)
|
||||
->first();
|
||||
|
||||
if ($existingAccount && ! $existingAccount->isDisconnected()) {
|
||||
session()->flash('flash.banner', __('accounts.flash.already_connected'));
|
||||
session()->flash('flash.bannerStyle', 'danger');
|
||||
|
||||
return back();
|
||||
}
|
||||
|
||||
return $this->redirectToProvider($request, $this->driver, $this->scopes);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -42,20 +42,9 @@ public function connect(Request $request): Response|RedirectResponse
|
|||
$this->authorize('manageAccounts', $workspace);
|
||||
$this->ensureSocialAccountLimit($workspace);
|
||||
|
||||
$existingAccount = $workspace->socialAccounts()
|
||||
->where('platform', $this->platform->value)
|
||||
->first();
|
||||
|
||||
if ($existingAccount && ! $existingAccount->isDisconnected()) {
|
||||
session()->flash('flash.banner', __('accounts.flash.already_connected'));
|
||||
session()->flash('flash.bannerStyle', 'danger');
|
||||
|
||||
return back();
|
||||
}
|
||||
|
||||
session([
|
||||
'social_connect_workspace' => $workspace->id,
|
||||
'social_reconnect_id' => $existingAccount?->id,
|
||||
'social_reconnect_id' => null,
|
||||
'social_connect_onboarding' => $request->boolean('onboarding'),
|
||||
]);
|
||||
|
||||
|
|
@ -76,14 +65,6 @@ public function callback(Request $request): View|RedirectResponse
|
|||
return $this->popupCallback(false, 'Workspace not found.', $this->platform->value);
|
||||
}
|
||||
|
||||
$reconnectId = session('social_reconnect_id');
|
||||
$existingAccount = $reconnectId ? $workspace->socialAccounts()->find($reconnectId) : null;
|
||||
|
||||
// If account exists and is connected, don't allow duplicate
|
||||
if (! $existingAccount && $workspace->hasConnectedPlatform($this->platform->value)) {
|
||||
return $this->popupCallback(false, 'This platform is already connected.', $this->platform->value);
|
||||
}
|
||||
|
||||
try {
|
||||
$socialUser = Socialite::driver($this->driver)->user();
|
||||
|
||||
|
|
@ -99,29 +80,6 @@ public function callback(Request $request): View|RedirectResponse
|
|||
$channel = $channels[0];
|
||||
$avatarPath = uploadFromUrl(data_get($channel, 'thumbnail'));
|
||||
|
||||
if ($existingAccount) {
|
||||
// Reconnect existing account
|
||||
$existingAccount->update([
|
||||
'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,
|
||||
'meta' => [
|
||||
'channel_id' => data_get($channel, 'id'),
|
||||
'google_user_id' => $socialUser->getId(),
|
||||
],
|
||||
]);
|
||||
$existingAccount->markAsConnected();
|
||||
|
||||
session()->forget('social_reconnect_id');
|
||||
|
||||
return $this->popupCallback(true, 'YouTube channel reconnected!', $this->platform->value);
|
||||
}
|
||||
|
||||
// Create new account
|
||||
$workspace->socialAccounts()->create([
|
||||
'platform' => $this->platform->value,
|
||||
|
|
@ -140,8 +98,6 @@ public function callback(Request $request): View|RedirectResponse
|
|||
],
|
||||
]);
|
||||
|
||||
session()->forget('social_reconnect_id');
|
||||
|
||||
return $this->popupCallback(true, 'YouTube channel connected!', $this->platform->value);
|
||||
}
|
||||
|
||||
|
|
@ -152,7 +108,6 @@ public function callback(Request $request): View|RedirectResponse
|
|||
'refresh_token' => $socialUser->refreshToken,
|
||||
'expires_in' => $socialUser->expiresIn,
|
||||
'user_id' => $socialUser->getId(),
|
||||
'reconnect_id' => $reconnectId,
|
||||
],
|
||||
]);
|
||||
|
||||
|
|
|
|||
|
|
@ -33,8 +33,6 @@ public function up(): void
|
|||
$table->timestamps();
|
||||
|
||||
$table->foreign('workspace_id')->references('id')->on('workspaces')->cascadeOnDelete();
|
||||
|
||||
$table->unique(['workspace_id', 'platform']);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,8 +2,14 @@
|
|||
|
||||
return [
|
||||
'title' => 'Connections',
|
||||
'page_title' => 'Connected Accounts',
|
||||
'description' => 'Connect your social networks to schedule and publish posts',
|
||||
'page_title' => 'Social Accounts',
|
||||
'description' => 'Overview of all your connected social accounts',
|
||||
'add_social' => 'Add Social',
|
||||
'add_social_title' => 'Connect a Social Account',
|
||||
'add_social_description' => 'Choose a platform to connect',
|
||||
'no_accounts' => 'No accounts connected yet',
|
||||
'no_accounts_description' => 'Connect your social networks to start scheduling and publishing posts',
|
||||
'added' => 'Added :date',
|
||||
|
||||
'limit_reached' => 'You have reached your plan limit for social accounts.',
|
||||
|
||||
|
|
|
|||
|
|
@ -2,8 +2,14 @@
|
|||
|
||||
return [
|
||||
'title' => 'Conexiones',
|
||||
'page_title' => 'Cuentas conectadas',
|
||||
'description' => 'Conecta tus redes sociales para programar y publicar posts',
|
||||
'page_title' => 'Cuentas Sociales',
|
||||
'description' => 'Resumen de todas tus cuentas sociales conectadas',
|
||||
'add_social' => 'Agregar Red Social',
|
||||
'add_social_title' => 'Conectar una Cuenta Social',
|
||||
'add_social_description' => 'Elige una plataforma para conectar',
|
||||
'no_accounts' => 'No hay cuentas conectadas todavía',
|
||||
'no_accounts_description' => 'Conecta tus redes sociales para empezar a programar y publicar posts',
|
||||
'added' => 'Agregada :date',
|
||||
|
||||
'limit_reached' => 'Has alcanzado el límite de cuentas sociales de tu plan.',
|
||||
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -2,8 +2,14 @@
|
|||
|
||||
return [
|
||||
'title' => 'Conexões',
|
||||
'page_title' => 'Contas Conectadas',
|
||||
'description' => 'Conecte suas redes sociais para agendar e publicar posts',
|
||||
'page_title' => 'Contas Sociais',
|
||||
'description' => 'Visão geral de todas as suas contas sociais conectadas',
|
||||
'add_social' => 'Adicionar Rede Social',
|
||||
'add_social_title' => 'Conectar uma Conta Social',
|
||||
'add_social_description' => 'Escolha uma plataforma para conectar',
|
||||
'no_accounts' => 'Nenhuma conta conectada ainda',
|
||||
'no_accounts_description' => 'Conecte suas redes sociais para começar a agendar e publicar posts',
|
||||
'added' => 'Adicionada :date',
|
||||
|
||||
'limit_reached' => 'Você atingiu o limite de contas sociais do seu plano.',
|
||||
|
||||
|
|
|
|||
129
resources/js/components/accounts/AddSocialDialog.vue
Normal file
129
resources/js/components/accounts/AddSocialDialog.vue
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
<script setup lang="ts">
|
||||
import { router } from '@inertiajs/vue3';
|
||||
import { IconInfoCircle } from '@tabler/icons-vue';
|
||||
import { trans } from 'laravel-vue-i18n';
|
||||
import { onMounted, onUnmounted } from 'vue';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
|
||||
export interface AvailablePlatform {
|
||||
value: string;
|
||||
label: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
defineProps<{
|
||||
platforms: AvailablePlatform[];
|
||||
}>();
|
||||
|
||||
const open = defineModel<boolean>('open', { default: false });
|
||||
|
||||
const getPlatformLogo = (platform: string): string => {
|
||||
const logos: Record<string, string> = {
|
||||
'linkedin': '/images/accounts/linkedin.png',
|
||||
'linkedin-page': '/images/accounts/linkedin.png',
|
||||
'x': '/images/accounts/x.png',
|
||||
'tiktok': '/images/accounts/tiktok.png',
|
||||
'instagram': '/images/accounts/instagram.png',
|
||||
'instagram-facebook': '/images/accounts/instagram.png',
|
||||
'facebook': '/images/accounts/facebook.png',
|
||||
'youtube': '/images/accounts/youtube.png',
|
||||
'threads': '/images/accounts/threads.png',
|
||||
'bluesky': '/images/accounts/bluesky.png',
|
||||
'pinterest': '/images/accounts/pinterest.png',
|
||||
'mastodon': '/images/accounts/mastodon.png',
|
||||
};
|
||||
return logos[platform] || '/images/accounts/linkedin.png';
|
||||
};
|
||||
|
||||
const getPlatformTooltip = (platform: string): string | null => {
|
||||
const tooltips: Record<string, string> = {
|
||||
'instagram-facebook': trans('accounts.tooltips.instagram_facebook'),
|
||||
'instagram': trans('accounts.tooltips.instagram_direct'),
|
||||
'bluesky': trans('accounts.tooltips.bluesky'),
|
||||
};
|
||||
return tooltips[platform] || null;
|
||||
};
|
||||
|
||||
const openOAuthPopup = (platformValue: string) => {
|
||||
const url = `/connect/${platformValue}`;
|
||||
const width = 600;
|
||||
const height = 700;
|
||||
const left = window.screenX + (window.outerWidth - width) / 2;
|
||||
const top = window.screenY + (window.outerHeight - height) / 2;
|
||||
|
||||
window.open(
|
||||
url,
|
||||
'oauth-popup',
|
||||
`width=${width},height=${height},left=${left},top=${top},scrollbars=yes,resizable=yes`,
|
||||
);
|
||||
};
|
||||
|
||||
const handleOAuthMessage = (event: MessageEvent) => {
|
||||
if (event.origin !== window.location.origin) return;
|
||||
if (event.data?.type !== 'social-oauth-callback') return;
|
||||
|
||||
open.value = false;
|
||||
router.reload();
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('message', handleOAuthMessage);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('message', handleOAuthMessage);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog v-model:open="open">
|
||||
<DialogContent class="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{{ $t('accounts.add_social_title') }}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{{ $t('accounts.add_social_description') }}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<Button
|
||||
v-for="platform in platforms"
|
||||
:key="platform.value"
|
||||
variant="outline"
|
||||
class="h-auto flex-col gap-2 py-4"
|
||||
@click="openOAuthPopup(platform.value)"
|
||||
>
|
||||
<div class="relative">
|
||||
<img :src="getPlatformLogo(platform.value)" :alt="platform.label" class="h-8 w-8 rounded-full object-contain" />
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="text-xs">
|
||||
<template v-if="platform.label.includes('(')">
|
||||
{{ platform.label.split('(')[0].trim() }}
|
||||
</template>
|
||||
<template v-else>{{ platform.label }}</template>
|
||||
</span>
|
||||
<TooltipProvider v-if="getPlatformTooltip(platform.value)">
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<IconInfoCircle class="h-3 w-3 shrink-0 text-muted-foreground cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" class="max-w-[250px]">
|
||||
<p>{{ getPlatformTooltip(platform.value) }}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
|
@ -1,33 +1,116 @@
|
|||
<script setup lang="ts">
|
||||
import { Head } from '@inertiajs/vue3';
|
||||
import { Head, router } from '@inertiajs/vue3';
|
||||
import { IconAffiliate, IconAlertCircle, IconCheck, IconExternalLink, IconPlus, IconRefresh, IconTrash } from '@tabler/icons-vue';
|
||||
import { trans } from 'laravel-vue-i18n';
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import AddSocialDialog, { type AvailablePlatform } from '@/components/accounts/AddSocialDialog.vue';
|
||||
import ConfirmDeleteModal from '@/components/ConfirmDeleteModal.vue';
|
||||
import SocialAccountsGrid, { type Platform } from '@/components/SocialAccountsGrid.vue';
|
||||
import EmptyState from '@/components/EmptyState.vue';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { formatDate } from '@/date';
|
||||
import AppLayout from '@/layouts/AppLayout.vue';
|
||||
import { accounts } from '@/routes/app';
|
||||
import { disconnect as disconnectAccount } from '@/routes/app/accounts';
|
||||
import { disconnect as disconnectAccount, toggle as toggleAccount } from '@/routes/app/accounts';
|
||||
import { type BreadcrumbItemType } from '@/types';
|
||||
|
||||
interface Workspace {
|
||||
interface SocialAccount {
|
||||
id: string;
|
||||
name: string;
|
||||
platform: string;
|
||||
platform_user_id: string;
|
||||
username: string;
|
||||
display_name: string;
|
||||
avatar_url: string;
|
||||
status: 'connected' | 'disconnected' | 'token_expired' | null;
|
||||
is_active: boolean;
|
||||
error_message: string | null;
|
||||
created_at: string;
|
||||
brand: { id: string; name: string } | null;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
workspace: Workspace;
|
||||
platforms: Platform[];
|
||||
accounts: SocialAccount[];
|
||||
platforms: AvailablePlatform[];
|
||||
}
|
||||
|
||||
defineProps<Props>();
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const isAddDialogOpen = ref(false);
|
||||
const deleteModal = ref<InstanceType<typeof ConfirmDeleteModal> | null>(null);
|
||||
|
||||
const breadcrumbs = computed<BreadcrumbItemType[]>(() => [
|
||||
{ title: trans('accounts.title'), href: accounts.url() },
|
||||
]);
|
||||
|
||||
const getPlatformLogo = (platform: string): string => {
|
||||
const logos: Record<string, string> = {
|
||||
'linkedin': '/images/accounts/linkedin.png',
|
||||
'linkedin-page': '/images/accounts/linkedin.png',
|
||||
'x': '/images/accounts/x.png',
|
||||
'tiktok': '/images/accounts/tiktok.png',
|
||||
'instagram': '/images/accounts/instagram.png',
|
||||
'instagram-facebook': '/images/accounts/instagram.png',
|
||||
'facebook': '/images/accounts/facebook.png',
|
||||
'youtube': '/images/accounts/youtube.png',
|
||||
'threads': '/images/accounts/threads.png',
|
||||
'bluesky': '/images/accounts/bluesky.png',
|
||||
'pinterest': '/images/accounts/pinterest.png',
|
||||
'mastodon': '/images/accounts/mastodon.png',
|
||||
};
|
||||
return logos[platform] || '/images/accounts/linkedin.png';
|
||||
};
|
||||
|
||||
const getProfileUrl = (platform: string, username: string | null, platformUserId: string | null = null): string | null => {
|
||||
if (platform === 'facebook') {
|
||||
const identifier = username || platformUserId;
|
||||
return identifier ? `https://facebook.com/${identifier}` : null;
|
||||
}
|
||||
|
||||
if (!username) return null;
|
||||
|
||||
const urls: Record<string, string> = {
|
||||
'linkedin': `https://linkedin.com/in/${username}`,
|
||||
'linkedin-page': `https://linkedin.com/company/${username}`,
|
||||
'x': `https://x.com/${username}`,
|
||||
'tiktok': `https://tiktok.com/@${username}`,
|
||||
'instagram': `https://instagram.com/${username}`,
|
||||
'instagram-facebook': `https://instagram.com/${username}`,
|
||||
'youtube': `https://youtube.com/@${username}`,
|
||||
'threads': `https://threads.net/@${username}`,
|
||||
'bluesky': `https://bsky.app/profile/${username}`,
|
||||
'pinterest': `https://pinterest.com/${username}`,
|
||||
};
|
||||
return urls[platform] || null;
|
||||
};
|
||||
|
||||
const isDisconnected = (account: SocialAccount): boolean => {
|
||||
return account.status === 'disconnected' || account.status === 'token_expired';
|
||||
};
|
||||
|
||||
const openOAuthPopup = (platformValue: string) => {
|
||||
const url = `/connect/${platformValue}`;
|
||||
const width = 600;
|
||||
const height = 700;
|
||||
const left = window.screenX + (window.outerWidth - width) / 2;
|
||||
const top = window.screenY + (window.outerHeight - height) / 2;
|
||||
|
||||
window.open(
|
||||
url,
|
||||
'oauth-popup',
|
||||
`width=${width},height=${height},left=${left},top=${top},scrollbars=yes,resizable=yes`,
|
||||
);
|
||||
};
|
||||
|
||||
const handleToggle = (accountId: string) => {
|
||||
router.put(toggleAccount.url(accountId), {}, {
|
||||
preserveScroll: true,
|
||||
});
|
||||
};
|
||||
|
||||
const handleDisconnect = (accountId: string) => {
|
||||
deleteModal.value?.open({
|
||||
url: disconnectAccount.url(accountId),
|
||||
|
|
@ -39,21 +122,143 @@ const handleDisconnect = (accountId: string) => {
|
|||
<Head :title="$t('accounts.page_title')" />
|
||||
|
||||
<AppLayout :breadcrumbs="breadcrumbs">
|
||||
<div class="flex flex-col gap-8 p-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold tracking-tight">{{ $t('accounts.page_title') }}</h1>
|
||||
<p class="text-muted-foreground">
|
||||
{{ $t('accounts.description') }}
|
||||
</p>
|
||||
</div>
|
||||
<template #header-right>
|
||||
<Button @click="isAddDialogOpen = true">
|
||||
{{ $t('accounts.add_social') }}
|
||||
</Button>
|
||||
</template>
|
||||
|
||||
<SocialAccountsGrid
|
||||
:platforms="platforms"
|
||||
@disconnect="handleDisconnect"
|
||||
<div class="flex flex-col gap-6 p-6">
|
||||
<EmptyState
|
||||
v-if="props.accounts.length === 0"
|
||||
:icon="IconAffiliate"
|
||||
:title="$t('accounts.no_accounts')"
|
||||
:description="$t('accounts.no_accounts_description')"
|
||||
/>
|
||||
|
||||
<div v-else class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
<Card
|
||||
v-for="account in props.accounts"
|
||||
:key="account.id"
|
||||
class="overflow-hidden"
|
||||
:class="{
|
||||
'border-red-500/30': isDisconnected(account),
|
||||
}"
|
||||
>
|
||||
<CardContent class="p-4">
|
||||
<div class="flex flex-col items-center text-center">
|
||||
<!-- Avatar with platform badge -->
|
||||
<div class="relative mb-3">
|
||||
<Avatar class="h-16 w-16">
|
||||
<AvatarImage v-if="account.avatar_url" :src="account.avatar_url" />
|
||||
<AvatarFallback class="text-lg">
|
||||
{{ account.display_name?.charAt(0) }}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<img
|
||||
:src="getPlatformLogo(account.platform)"
|
||||
:alt="account.platform"
|
||||
class="absolute -bottom-1 -right-1 h-6 w-6 rounded-full border-2 border-background object-contain"
|
||||
/>
|
||||
<div
|
||||
v-if="!isDisconnected(account)"
|
||||
class="absolute -top-0.5 -right-0.5 flex h-4 w-4 items-center justify-center rounded-full bg-green-500 text-white ring-2 ring-background"
|
||||
>
|
||||
<IconCheck class="h-2 w-2" />
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="absolute -top-0.5 -right-0.5 flex h-4 w-4 items-center justify-center rounded-full bg-red-500 text-white ring-2 ring-background"
|
||||
>
|
||||
<IconAlertCircle class="h-2 w-2" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Name and username -->
|
||||
<p class="font-semibold truncate max-w-full">{{ account.display_name }}</p>
|
||||
<p class="text-sm text-muted-foreground truncate max-w-full">
|
||||
@{{ account.username || account.display_name }}
|
||||
</p>
|
||||
|
||||
<!-- Brand badge -->
|
||||
<p v-if="account.brand" class="mt-1 text-xs text-muted-foreground">
|
||||
{{ account.brand.name }}
|
||||
</p>
|
||||
|
||||
<!-- Added date -->
|
||||
<p class="mt-2 text-xs text-muted-foreground">
|
||||
{{ $t('accounts.added', { date: formatDate(account.created_at) }) }}
|
||||
</p>
|
||||
|
||||
<!-- Disconnected warning -->
|
||||
<div v-if="isDisconnected(account)" class="mt-3 w-full rounded-md bg-red-100 px-2 py-1.5 text-xs text-red-700 dark:bg-red-900/30 dark:text-red-400">
|
||||
{{ $t('accounts.connection_lost') }}
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="mt-3 flex items-center gap-1">
|
||||
<TooltipProvider v-if="isDisconnected(account)">
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<Button variant="ghost" size="icon" class="h-8 w-8 text-amber-600 hover:text-amber-700" @click="openOAuthPopup(account.platform)">
|
||||
<IconRefresh class="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{{ $t('accounts.reconnect_account') }}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
<TooltipProvider v-if="getProfileUrl(account.platform, account.username, account.platform_user_id)">
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<Button variant="ghost" size="icon" class="h-8 w-8" as-child>
|
||||
<a :href="getProfileUrl(account.platform, account.username, account.platform_user_id)!" target="_blank">
|
||||
<IconExternalLink class="h-4 w-4" />
|
||||
</a>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{{ $t('accounts.view_profile') }}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
<Switch :model-value="account.is_active" @update:model-value="handleToggle(account.id)" />
|
||||
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<Button variant="ghost" size="icon" class="h-8 w-8 text-destructive hover:text-destructive" @click="handleDisconnect(account.id)">
|
||||
<IconTrash class="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{{ $t('accounts.disconnect') }}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- Add Social Card -->
|
||||
<Card class="cursor-pointer overflow-hidden border-dashed transition-all hover:border-primary hover:shadow-md" @click="isAddDialogOpen = true">
|
||||
<CardContent class="flex h-full flex-col items-center justify-center p-4">
|
||||
<div class="flex h-16 w-16 items-center justify-center rounded-full bg-muted mb-3">
|
||||
<IconPlus class="h-6 w-6 text-muted-foreground" />
|
||||
</div>
|
||||
<p class="font-semibold">{{ $t('accounts.add_social') }}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</AppLayout>
|
||||
|
||||
<AddSocialDialog v-model:open="isAddDialogOpen" :platforms="platforms" />
|
||||
|
||||
<ConfirmDeleteModal
|
||||
ref="deleteModal"
|
||||
:title="$t('accounts.disconnect_modal.title')"
|
||||
|
|
|
|||
|
|
@ -79,7 +79,7 @@
|
|||
]);
|
||||
});
|
||||
|
||||
test('user cannot connect bluesky if already connected', function () {
|
||||
test('user can connect multiple bluesky accounts', function () {
|
||||
SocialAccount::factory()->bluesky()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'platform_user_id' => 'did:plc:existing123',
|
||||
|
|
@ -104,43 +104,10 @@
|
|||
'password' => 'xxxx-xxxx-xxxx-xxxx',
|
||||
]);
|
||||
|
||||
$response->assertRedirect();
|
||||
$response->assertSessionHasErrors('identifier');
|
||||
|
||||
expect($this->workspace->socialAccounts()->where('platform', Platform::Bluesky)->count())->toBe(1);
|
||||
});
|
||||
|
||||
test('user can reconnect disconnected bluesky account', function () {
|
||||
$existingAccount = SocialAccount::factory()->bluesky()->disconnected()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'platform_user_id' => 'did:plc:testuser123',
|
||||
]);
|
||||
|
||||
Http::fake([
|
||||
'https://bsky.social/xrpc/com.atproto.server.createSession' => Http::response([
|
||||
'did' => 'did:plc:testuser123',
|
||||
'handle' => 'testuser.bsky.social',
|
||||
'accessJwt' => 'new-access-token',
|
||||
'refreshJwt' => 'new-refresh-token',
|
||||
], 200),
|
||||
'https://bsky.social/xrpc/app.bsky.actor.getProfile*' => Http::response([
|
||||
'did' => 'did:plc:testuser123',
|
||||
'handle' => 'testuser.bsky.social',
|
||||
'displayName' => 'Test User',
|
||||
], 200),
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($this->user)->post(route('app.social.bluesky.store'), [
|
||||
'identifier' => 'testuser.bsky.social',
|
||||
'password' => 'xxxx-xxxx-xxxx-xxxx',
|
||||
]);
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertViewHas('success', true);
|
||||
|
||||
$existingAccount->refresh();
|
||||
expect($existingAccount->status)->toBe(Status::Connected);
|
||||
expect($existingAccount->access_token)->toBe('new-access-token');
|
||||
expect($this->workspace->socialAccounts()->where('platform', Platform::Bluesky)->count())->toBe(2);
|
||||
});
|
||||
|
||||
test('bluesky connection validates required fields', function () {
|
||||
|
|
|
|||
|
|
@ -159,7 +159,7 @@
|
|||
$response->assertViewHas('message', 'Session expired. Please try again.');
|
||||
});
|
||||
|
||||
test('user cannot connect facebook if already connected', function () {
|
||||
test('user can connect multiple facebook accounts', function () {
|
||||
SocialAccount::factory()->facebook()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'platform_user_id' => 'page_existing',
|
||||
|
|
@ -192,53 +192,10 @@
|
|||
|
||||
$response = $this->actingAs($this->user)->get(route('app.social.facebook.callback'));
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertViewHas('success', false);
|
||||
$response->assertViewHas('message', 'This platform is already connected.');
|
||||
});
|
||||
|
||||
test('user can reconnect disconnected facebook account', function () {
|
||||
$existingAccount = SocialAccount::factory()->facebook()->disconnected()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'platform_user_id' => 'page_123',
|
||||
'meta' => ['page_id' => 'page_123'],
|
||||
]);
|
||||
|
||||
session([
|
||||
'social_connect_workspace' => $this->workspace->id,
|
||||
'social_reconnect_id' => $existingAccount->id,
|
||||
]);
|
||||
|
||||
$socialiteUser = Mockery::mock(SocialiteUser::class);
|
||||
$socialiteUser->shouldReceive('getId')->andReturn('facebook_user_123');
|
||||
$socialiteUser->token = 'new-user-token';
|
||||
|
||||
Socialite::shouldReceive('driver')
|
||||
->with('facebook')
|
||||
->andReturn(Mockery::mock()->shouldReceive('usingGraphVersion')->andReturnSelf()->shouldReceive('user')->andReturn($socialiteUser)->getMock());
|
||||
|
||||
Http::fake([
|
||||
'https://graph.facebook.com/*/me/accounts*' => Http::response([
|
||||
'data' => [
|
||||
[
|
||||
'id' => 'page_123',
|
||||
'name' => 'My Facebook Page',
|
||||
'username' => 'myfbpage',
|
||||
'picture' => ['data' => ['url' => null]],
|
||||
'access_token' => 'new-page-token',
|
||||
],
|
||||
],
|
||||
], 200),
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($this->user)->get(route('app.social.facebook.callback'));
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertViewHas('success', true);
|
||||
|
||||
$existingAccount->refresh();
|
||||
expect($existingAccount->status)->toBe(Status::Connected);
|
||||
expect($existingAccount->access_token)->toBe('new-page-token');
|
||||
expect($this->workspace->socialAccounts()->where('platform', Platform::Facebook)->count())->toBe(2);
|
||||
});
|
||||
|
||||
test('facebook callback handles oauth errors gracefully', function () {
|
||||
|
|
|
|||
|
|
@ -82,7 +82,7 @@
|
|||
$response->assertViewHas('message', 'Session expired. Please try again.');
|
||||
});
|
||||
|
||||
test('user cannot connect instagram if already connected', function () {
|
||||
test('user can connect multiple instagram accounts', function () {
|
||||
SocialAccount::factory()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'platform' => Platform::Instagram,
|
||||
|
|
@ -112,47 +112,10 @@
|
|||
|
||||
$response = $this->actingAs($this->user)->get(route('app.social.instagram.callback'));
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertViewHas('success', false);
|
||||
$response->assertViewHas('message', 'This platform is already connected.');
|
||||
});
|
||||
|
||||
test('user can reconnect disconnected instagram account', function () {
|
||||
$existingAccount = SocialAccount::factory()->disconnected()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'platform' => Platform::Instagram,
|
||||
'platform_user_id' => '12345678',
|
||||
]);
|
||||
|
||||
session([
|
||||
'social_connect_workspace' => $this->workspace->id,
|
||||
'social_reconnect_id' => $existingAccount->id,
|
||||
]);
|
||||
|
||||
$socialiteUser = Mockery::mock(SocialiteUser::class);
|
||||
$socialiteUser->shouldReceive('getId')->andReturn('12345678');
|
||||
$socialiteUser->shouldReceive('getNickname')->andReturn('testuser');
|
||||
$socialiteUser->shouldReceive('getName')->andReturn('Test User');
|
||||
$socialiteUser->shouldReceive('getAvatar')->andReturn(null);
|
||||
$socialiteUser->token = 'new-access-token';
|
||||
$socialiteUser->refreshToken = 'new-refresh-token';
|
||||
$socialiteUser->expiresIn = 5184000;
|
||||
$socialiteUser->user = ['account_type' => 'BUSINESS'];
|
||||
|
||||
Socialite::shouldReceive('driver')
|
||||
->with('instagram')
|
||||
->andReturn(Mockery::mock([
|
||||
'user' => $socialiteUser,
|
||||
]));
|
||||
|
||||
$response = $this->actingAs($this->user)->get(route('app.social.instagram.callback'));
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertViewHas('success', true);
|
||||
|
||||
$existingAccount->refresh();
|
||||
expect($existingAccount->status)->toBe(Status::Connected);
|
||||
expect($existingAccount->access_token)->toBe('new-access-token');
|
||||
expect($this->workspace->socialAccounts()->where('platform', Platform::Instagram)->count())->toBe(2);
|
||||
});
|
||||
|
||||
test('instagram callback handles oauth errors gracefully', function () {
|
||||
|
|
|
|||
|
|
@ -94,7 +94,7 @@
|
|||
$response->assertViewHas('message', 'Session expired. Please try again.');
|
||||
});
|
||||
|
||||
test('user cannot connect linkedin if already connected', function () {
|
||||
test('user can connect multiple linkedin accounts', function () {
|
||||
SocialAccount::factory()->linkedin()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'platform_user_id' => 'abc123xyz',
|
||||
|
|
@ -112,38 +112,6 @@
|
|||
$socialiteUser->token = 'new-access-token';
|
||||
$socialiteUser->refreshToken = 'new-refresh-token';
|
||||
$socialiteUser->expiresIn = 5184000;
|
||||
|
||||
Socialite::shouldReceive('driver')
|
||||
->with('linkedin')
|
||||
->andReturn(Mockery::mock([
|
||||
'user' => $socialiteUser,
|
||||
]));
|
||||
|
||||
$response = $this->actingAs($this->user)->get(route('app.social.linkedin.callback'));
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertViewHas('success', false);
|
||||
$response->assertViewHas('message', 'This platform is already connected.');
|
||||
});
|
||||
|
||||
test('user can reconnect disconnected linkedin account', function () {
|
||||
$existingAccount = SocialAccount::factory()->linkedin()->disconnected()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'platform_user_id' => 'abc123xyz',
|
||||
]);
|
||||
|
||||
session([
|
||||
'social_connect_workspace' => $this->workspace->id,
|
||||
]);
|
||||
|
||||
$socialiteUser = Mockery::mock(SocialiteUser::class);
|
||||
$socialiteUser->shouldReceive('getId')->andReturn('abc123xyz');
|
||||
$socialiteUser->shouldReceive('getNickname')->andReturn(null);
|
||||
$socialiteUser->shouldReceive('getName')->andReturn('John Doe');
|
||||
$socialiteUser->shouldReceive('getAvatar')->andReturn(null);
|
||||
$socialiteUser->token = 'new-access-token';
|
||||
$socialiteUser->refreshToken = 'new-refresh-token';
|
||||
$socialiteUser->expiresIn = 5184000;
|
||||
$socialiteUser->approvedScopes = ['openid', 'profile', 'email', 'w_member_social'];
|
||||
|
||||
Socialite::shouldReceive('driver')
|
||||
|
|
@ -154,7 +122,7 @@
|
|||
|
||||
Http::fake([
|
||||
'https://api.linkedin.com/v2/me*' => Http::response([
|
||||
'vanityName' => 'johndoe',
|
||||
'vanityName' => 'janedoe',
|
||||
], 200),
|
||||
]);
|
||||
|
||||
|
|
@ -163,9 +131,7 @@
|
|||
$response->assertOk();
|
||||
$response->assertViewHas('success', true);
|
||||
|
||||
$existingAccount->refresh();
|
||||
expect($existingAccount->status)->toBe(Status::Connected);
|
||||
expect($existingAccount->access_token)->toBe('new-access-token');
|
||||
expect($this->workspace->socialAccounts()->where('platform', Platform::LinkedIn)->count())->toBe(2);
|
||||
});
|
||||
|
||||
test('linkedin callback handles oauth errors gracefully', function () {
|
||||
|
|
|
|||
|
|
@ -180,26 +180,12 @@
|
|||
$response->assertViewHas('message', 'Session expired. Please try again.');
|
||||
});
|
||||
|
||||
test('user cannot connect linkedin page if already connected via connect route', function () {
|
||||
test('user can connect multiple linkedin pages', function () {
|
||||
SocialAccount::factory()->linkedinPage()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'platform_user_id' => '123456',
|
||||
]);
|
||||
|
||||
// The "already connected" check happens in the connect method, not callback
|
||||
$response = $this->actingAs($this->user)->get(route('app.social.linkedin-page.connect'));
|
||||
|
||||
$response->assertRedirect();
|
||||
$response->assertSessionHas('flash.banner', __('accounts.flash.already_connected'));
|
||||
$response->assertSessionHas('flash.bannerStyle', 'danger');
|
||||
});
|
||||
|
||||
test('user can reconnect disconnected linkedin page account', function () {
|
||||
$existingAccount = SocialAccount::factory()->linkedinPage()->disconnected()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'platform_user_id' => '123456',
|
||||
]);
|
||||
|
||||
session([
|
||||
'linkedin_page_pending' => [
|
||||
'workspace_id' => $this->workspace->id,
|
||||
|
|
@ -210,25 +196,22 @@
|
|||
'refresh_token' => 'new-refresh-token',
|
||||
'expires_in' => 5184000,
|
||||
'organizations' => [
|
||||
['id' => 123456, 'name' => 'Test Company', 'vanity_name' => 'testcompany', 'logo' => null],
|
||||
['id' => 789012, 'name' => 'Another Company', 'vanity_name' => 'anothercompany', 'logo' => null],
|
||||
],
|
||||
'reconnect_id' => $existingAccount->id,
|
||||
],
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($this->user)->post(route('app.social.linkedin-page.select'), [
|
||||
'organization_id' => 123456,
|
||||
'organization_name' => 'Test Company',
|
||||
'organization_vanity_name' => 'testcompany',
|
||||
'organization_id' => 789012,
|
||||
'organization_name' => 'Another Company',
|
||||
'organization_vanity_name' => 'anothercompany',
|
||||
'organization_logo' => null,
|
||||
]);
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertViewHas('success', true);
|
||||
|
||||
$existingAccount->refresh();
|
||||
expect($existingAccount->status)->toBe(Status::Connected);
|
||||
expect($existingAccount->access_token)->toBe('new-access-token');
|
||||
expect($this->workspace->socialAccounts()->where('platform', Platform::LinkedInPage)->count())->toBe(2);
|
||||
});
|
||||
|
||||
test('linkedin page callback handles oauth errors gracefully', function () {
|
||||
|
|
|
|||
|
|
@ -140,7 +140,7 @@
|
|||
$response->assertViewHas('message', 'Session expired. Please try again.');
|
||||
});
|
||||
|
||||
test('user cannot connect mastodon if already connected', function () {
|
||||
test('user can connect multiple mastodon accounts', function () {
|
||||
SocialAccount::factory()->mastodon()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'platform_user_id' => '123456789',
|
||||
|
|
@ -172,49 +172,10 @@
|
|||
'state' => 'test-state',
|
||||
]));
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertViewHas('success', false);
|
||||
$response->assertViewHas('message', 'Mastodon is already connected.');
|
||||
});
|
||||
|
||||
test('user can reconnect disconnected mastodon account', function () {
|
||||
$existingAccount = SocialAccount::factory()->mastodon()->disconnected()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'platform_user_id' => '123456789',
|
||||
]);
|
||||
|
||||
session([
|
||||
'mastodon_instance' => 'https://mastodon.social',
|
||||
'mastodon_client_id' => 'test-client-id',
|
||||
'mastodon_client_secret' => 'test-client-secret',
|
||||
'mastodon_oauth_state' => 'test-state',
|
||||
'social_connect_workspace' => $this->workspace->id,
|
||||
]);
|
||||
|
||||
Http::fake([
|
||||
'https://mastodon.social/oauth/token' => Http::response([
|
||||
'access_token' => 'new-access-token',
|
||||
'token_type' => 'Bearer',
|
||||
], 200),
|
||||
'https://mastodon.social/api/v1/accounts/verify_credentials' => Http::response([
|
||||
'id' => '123456789',
|
||||
'username' => 'testuser',
|
||||
'acct' => 'testuser',
|
||||
'display_name' => 'Test User',
|
||||
], 200),
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($this->user)->get(route('app.social.mastodon.callback', [
|
||||
'code' => 'test-auth-code',
|
||||
'state' => 'test-state',
|
||||
]));
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertViewHas('success', true);
|
||||
|
||||
$existingAccount->refresh();
|
||||
expect($existingAccount->status)->toBe(Status::Connected);
|
||||
expect($existingAccount->access_token)->toBe('new-access-token');
|
||||
expect($this->workspace->socialAccounts()->where('platform', Platform::Mastodon)->count())->toBe(2);
|
||||
});
|
||||
|
||||
test('mastodon connection validates instance url', function () {
|
||||
|
|
|
|||
|
|
@ -84,7 +84,7 @@
|
|||
$response->assertViewHas('message', 'Session expired. Please try again.');
|
||||
});
|
||||
|
||||
test('user cannot connect pinterest if already connected', function () {
|
||||
test('user can connect multiple pinterest accounts', function () {
|
||||
SocialAccount::factory()->pinterest()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'platform_user_id' => 'pinterest_user_123',
|
||||
|
|
@ -102,38 +102,6 @@
|
|||
$socialiteUser->token = 'new-access-token';
|
||||
$socialiteUser->refreshToken = 'new-refresh-token';
|
||||
$socialiteUser->expiresIn = 2592000;
|
||||
|
||||
Socialite::shouldReceive('driver')
|
||||
->with('pinterest')
|
||||
->andReturn(Mockery::mock([
|
||||
'user' => $socialiteUser,
|
||||
]));
|
||||
|
||||
$response = $this->actingAs($this->user)->get(route('app.social.pinterest.callback'));
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertViewHas('success', false);
|
||||
$response->assertViewHas('message', 'This platform is already connected.');
|
||||
});
|
||||
|
||||
test('user can reconnect disconnected pinterest account', function () {
|
||||
$existingAccount = SocialAccount::factory()->pinterest()->disconnected()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'platform_user_id' => 'pinterest_user_123',
|
||||
]);
|
||||
|
||||
session([
|
||||
'social_connect_workspace' => $this->workspace->id,
|
||||
]);
|
||||
|
||||
$socialiteUser = Mockery::mock(SocialiteUser::class);
|
||||
$socialiteUser->shouldReceive('getId')->andReturn('pinterest_user_123');
|
||||
$socialiteUser->shouldReceive('getNickname')->andReturn('pinner');
|
||||
$socialiteUser->shouldReceive('getName')->andReturn('Pinterest User');
|
||||
$socialiteUser->shouldReceive('getAvatar')->andReturn(null);
|
||||
$socialiteUser->token = 'new-access-token';
|
||||
$socialiteUser->refreshToken = 'new-refresh-token';
|
||||
$socialiteUser->expiresIn = 2592000;
|
||||
$socialiteUser->approvedScopes = ['boards:read', 'boards:write', 'pins:read', 'pins:write', 'user_accounts:read'];
|
||||
|
||||
Socialite::shouldReceive('driver')
|
||||
|
|
@ -147,9 +115,7 @@
|
|||
$response->assertOk();
|
||||
$response->assertViewHas('success', true);
|
||||
|
||||
$existingAccount->refresh();
|
||||
expect($existingAccount->status)->toBe(Status::Connected);
|
||||
expect($existingAccount->access_token)->toBe('new-access-token');
|
||||
expect($this->workspace->socialAccounts()->where('platform', Platform::Pinterest)->count())->toBe(2);
|
||||
});
|
||||
|
||||
test('pinterest callback handles oauth errors gracefully', function () {
|
||||
|
|
|
|||
|
|
@ -105,7 +105,7 @@
|
|||
$response->assertViewHas('message', 'Session expired. Please try again.');
|
||||
});
|
||||
|
||||
test('user cannot connect threads if already connected', function () {
|
||||
test('user can connect multiple threads accounts', function () {
|
||||
SocialAccount::factory()->threads()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'platform_user_id' => '123456789',
|
||||
|
|
@ -139,52 +139,10 @@
|
|||
'state' => $state,
|
||||
]));
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertViewHas('success', false);
|
||||
$response->assertViewHas('message', 'This platform is already connected.');
|
||||
});
|
||||
|
||||
test('user can reconnect disconnected threads account', function () {
|
||||
$existingAccount = SocialAccount::factory()->threads()->disconnected()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'platform_user_id' => '123456789',
|
||||
]);
|
||||
|
||||
$state = bin2hex(random_bytes(16));
|
||||
|
||||
session([
|
||||
'social_connect_workspace' => $this->workspace->id,
|
||||
'threads_oauth_state' => $state,
|
||||
'social_reconnect_id' => $existingAccount->id,
|
||||
]);
|
||||
|
||||
Http::fake([
|
||||
'https://graph.threads.net/oauth/access_token' => Http::response([
|
||||
'access_token' => 'new-short-token',
|
||||
'user_id' => '123456789',
|
||||
], 200),
|
||||
'https://graph.threads.net/access_token*' => Http::response([
|
||||
'access_token' => 'new-long-lived-token',
|
||||
'expires_in' => 5184000,
|
||||
], 200),
|
||||
'https://graph.threads.net/v1.0/123456789*' => Http::response([
|
||||
'id' => '123456789',
|
||||
'username' => 'testuser',
|
||||
'name' => 'Test User',
|
||||
], 200),
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($this->user)->get(route('app.social.threads.callback', [
|
||||
'code' => 'test-auth-code',
|
||||
'state' => $state,
|
||||
]));
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertViewHas('success', true);
|
||||
|
||||
$existingAccount->refresh();
|
||||
expect($existingAccount->status)->toBe(Status::Connected);
|
||||
expect($existingAccount->access_token)->toBe('new-long-lived-token');
|
||||
expect($this->workspace->socialAccounts()->where('platform', Platform::Threads)->count())->toBe(2);
|
||||
});
|
||||
|
||||
test('threads callback handles token exchange failure', function () {
|
||||
|
|
|
|||
|
|
@ -86,7 +86,7 @@
|
|||
$response->assertViewHas('message', 'Session expired. Please try again.');
|
||||
});
|
||||
|
||||
test('user cannot connect tiktok if already connected', function () {
|
||||
test('user can connect multiple tiktok accounts', function () {
|
||||
SocialAccount::factory()->tiktok()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'platform_user_id' => 'tiktok123',
|
||||
|
|
@ -104,41 +104,6 @@
|
|||
$socialiteUser->token = 'new-access-token';
|
||||
$socialiteUser->refreshToken = 'new-refresh-token';
|
||||
$socialiteUser->expiresIn = 86400;
|
||||
|
||||
$socialiteMock = Mockery::mock();
|
||||
$socialiteMock->shouldReceive('scopes')->andReturn($socialiteMock);
|
||||
$socialiteMock->shouldReceive('user')->andReturn($socialiteUser);
|
||||
|
||||
Socialite::shouldReceive('driver')
|
||||
->with('tiktok')
|
||||
->andReturn($socialiteMock);
|
||||
|
||||
$response = $this->actingAs($this->user)->get(route('app.social.tiktok.callback'));
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertViewHas('success', false);
|
||||
$response->assertViewHas('message', 'This platform is already connected.');
|
||||
});
|
||||
|
||||
test('user can reconnect disconnected tiktok account', function () {
|
||||
$existingAccount = SocialAccount::factory()->tiktok()->disconnected()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'platform_user_id' => 'tiktok123',
|
||||
]);
|
||||
|
||||
session([
|
||||
'social_connect_workspace' => $this->workspace->id,
|
||||
'social_reconnect_id' => $existingAccount->id,
|
||||
]);
|
||||
|
||||
$socialiteUser = Mockery::mock(SocialiteUser::class);
|
||||
$socialiteUser->shouldReceive('getId')->andReturn('tiktok123');
|
||||
$socialiteUser->shouldReceive('getNickname')->andReturn('tiktoker');
|
||||
$socialiteUser->shouldReceive('getName')->andReturn('TikTok User');
|
||||
$socialiteUser->shouldReceive('getAvatar')->andReturn(null);
|
||||
$socialiteUser->token = 'new-access-token';
|
||||
$socialiteUser->refreshToken = 'new-refresh-token';
|
||||
$socialiteUser->expiresIn = 86400;
|
||||
$socialiteUser->approvedScopes = ['user.info.basic', 'user.info.profile', 'video.publish'];
|
||||
|
||||
$socialiteMock = Mockery::mock();
|
||||
|
|
@ -154,9 +119,7 @@
|
|||
$response->assertOk();
|
||||
$response->assertViewHas('success', true);
|
||||
|
||||
$existingAccount->refresh();
|
||||
expect($existingAccount->status)->toBe(Status::Connected);
|
||||
expect($existingAccount->access_token)->toBe('new-access-token');
|
||||
expect($this->workspace->socialAccounts()->where('platform', Platform::TikTok)->count())->toBe(2);
|
||||
});
|
||||
|
||||
test('tiktok callback handles oauth errors gracefully', function () {
|
||||
|
|
|
|||
|
|
@ -84,7 +84,7 @@
|
|||
$response->assertViewHas('message', 'Session expired. Please try again.');
|
||||
});
|
||||
|
||||
test('user cannot connect x if already connected', function () {
|
||||
test('user can connect multiple x accounts', function () {
|
||||
SocialAccount::factory()->x()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'platform_user_id' => '123456789',
|
||||
|
|
@ -102,38 +102,6 @@
|
|||
$socialiteUser->token = 'new-access-token';
|
||||
$socialiteUser->refreshToken = 'new-refresh-token';
|
||||
$socialiteUser->expiresIn = 7200;
|
||||
|
||||
Socialite::shouldReceive('driver')
|
||||
->with('x')
|
||||
->andReturn(Mockery::mock([
|
||||
'user' => $socialiteUser,
|
||||
]));
|
||||
|
||||
$response = $this->actingAs($this->user)->get(route('app.social.x.callback'));
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertViewHas('success', false);
|
||||
$response->assertViewHas('message', 'This platform is already connected.');
|
||||
});
|
||||
|
||||
test('user can reconnect disconnected x account', function () {
|
||||
$existingAccount = SocialAccount::factory()->x()->disconnected()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'platform_user_id' => '123456789',
|
||||
]);
|
||||
|
||||
session([
|
||||
'social_connect_workspace' => $this->workspace->id,
|
||||
]);
|
||||
|
||||
$socialiteUser = Mockery::mock(SocialiteUser::class);
|
||||
$socialiteUser->shouldReceive('getId')->andReturn('123456789');
|
||||
$socialiteUser->shouldReceive('getNickname')->andReturn('testuser');
|
||||
$socialiteUser->shouldReceive('getName')->andReturn('Test User');
|
||||
$socialiteUser->shouldReceive('getAvatar')->andReturn(null);
|
||||
$socialiteUser->token = 'new-access-token';
|
||||
$socialiteUser->refreshToken = 'new-refresh-token';
|
||||
$socialiteUser->expiresIn = 7200;
|
||||
$socialiteUser->approvedScopes = ['tweet.read', 'tweet.write'];
|
||||
|
||||
Socialite::shouldReceive('driver')
|
||||
|
|
@ -147,9 +115,7 @@
|
|||
$response->assertOk();
|
||||
$response->assertViewHas('success', true);
|
||||
|
||||
$existingAccount->refresh();
|
||||
expect($existingAccount->status)->toBe(Status::Connected);
|
||||
expect($existingAccount->access_token)->toBe('new-access-token');
|
||||
expect($this->workspace->socialAccounts()->where('platform', Platform::X)->count())->toBe(2);
|
||||
});
|
||||
|
||||
test('x callback handles oauth errors gracefully', function () {
|
||||
|
|
|
|||
|
|
@ -182,7 +182,7 @@
|
|||
$response->assertViewHas('message', 'Session expired. Please try again.');
|
||||
});
|
||||
|
||||
test('user cannot connect youtube if already connected', function () {
|
||||
test('user can connect multiple youtube accounts', function () {
|
||||
SocialAccount::factory()->youtube()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'platform_user_id' => 'UC_channel_123',
|
||||
|
|
@ -222,59 +222,10 @@
|
|||
|
||||
$response = $this->actingAs($this->user)->get(route('app.social.youtube.callback'));
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertViewHas('success', false);
|
||||
$response->assertViewHas('message', 'This platform is already connected.');
|
||||
});
|
||||
|
||||
test('user can reconnect disconnected youtube account', function () {
|
||||
$existingAccount = SocialAccount::factory()->youtube()->disconnected()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'platform_user_id' => 'UC_channel_123',
|
||||
'meta' => ['channel_id' => 'UC_channel_123'],
|
||||
]);
|
||||
|
||||
session([
|
||||
'social_connect_workspace' => $this->workspace->id,
|
||||
'social_reconnect_id' => $existingAccount->id,
|
||||
]);
|
||||
|
||||
$socialiteUser = Mockery::mock(SocialiteUser::class);
|
||||
$socialiteUser->shouldReceive('getId')->andReturn('google_user_123');
|
||||
$socialiteUser->token = 'new-access-token';
|
||||
$socialiteUser->refreshToken = 'new-refresh-token';
|
||||
$socialiteUser->expiresIn = 3600;
|
||||
|
||||
Socialite::shouldReceive('driver')
|
||||
->with('google')
|
||||
->andReturn(Mockery::mock([
|
||||
'user' => $socialiteUser,
|
||||
]));
|
||||
|
||||
Http::fake([
|
||||
'https://www.googleapis.com/youtube/v3/channels*' => Http::response([
|
||||
'items' => [
|
||||
[
|
||||
'id' => 'UC_channel_123',
|
||||
'snippet' => [
|
||||
'title' => 'My YouTube Channel',
|
||||
'customUrl' => '@mychannel',
|
||||
'thumbnails' => ['default' => ['url' => null]],
|
||||
],
|
||||
'statistics' => ['subscriberCount' => 1000],
|
||||
],
|
||||
],
|
||||
], 200),
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($this->user)->get(route('app.social.youtube.callback'));
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertViewHas('success', true);
|
||||
|
||||
$existingAccount->refresh();
|
||||
expect($existingAccount->status)->toBe(Status::Connected);
|
||||
expect($existingAccount->access_token)->toBe('new-access-token');
|
||||
expect($this->workspace->socialAccounts()->where('platform', Platform::YouTube)->count())->toBe(2);
|
||||
});
|
||||
|
||||
test('youtube callback handles oauth errors gracefully', function () {
|
||||
|
|
|
|||
Loading…
Reference in a new issue