Skip to content

Commit a976e11

Browse files
fix: refresh tokens before verifying social connections and fix dark mode
- ConnectionVerifier now refreshes expired/expiring tokens before verification - Prevents false disconnection emails when tokens are expired but refreshable - Added refresh methods for LinkedIn, X, Bluesky, YouTube, TikTok, Pinterest, Threads - Fixed dark mode for calendar post items (status colors) - Fixed dark mode for platform tabs in post editor - Added tests for token refresh behavior
1 parent cd22260 commit a976e11

4 files changed

Lines changed: 548 additions & 21 deletions

File tree

app/Services/Social/ConnectionVerifier.php

Lines changed: 251 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
use App\Exceptions\TokenExpiredException;
77
use App\Models\SocialAccount;
88
use Illuminate\Support\Facades\Http;
9+
use Illuminate\Support\Facades\Log;
910

1011
class ConnectionVerifier
1112
{
@@ -16,6 +17,11 @@ class ConnectionVerifier
1617
*/
1718
public function verify(SocialAccount $account): bool
1819
{
20+
// Refresh token if expired or expiring soon before verifying
21+
if ($account->is_token_expired || $account->is_token_expiring_soon) {
22+
$this->refreshTokenIfNeeded($account);
23+
}
24+
1925
return match ($account->platform) {
2026
Platform::LinkedIn => $this->verifyLinkedIn($account),
2127
Platform::LinkedInPage => $this->verifyLinkedInPage($account),
@@ -31,6 +37,251 @@ public function verify(SocialAccount $account): bool
3137
};
3238
}
3339

40+
/**
41+
* Refresh token based on platform type.
42+
*
43+
* @throws TokenExpiredException if refresh fails
44+
*/
45+
private function refreshTokenIfNeeded(SocialAccount $account): void
46+
{
47+
match ($account->platform) {
48+
Platform::LinkedIn, Platform::LinkedInPage => $this->refreshLinkedInToken($account),
49+
Platform::X => $this->refreshXToken($account),
50+
Platform::Bluesky => $this->refreshBlueskyToken($account),
51+
Platform::YouTube => $this->refreshYouTubeToken($account),
52+
Platform::TikTok => $this->refreshTikTokToken($account),
53+
Platform::Pinterest => $this->refreshPinterestToken($account),
54+
Platform::Threads => $this->refreshThreadsToken($account),
55+
// Facebook, Instagram use long-lived tokens without refresh mechanism
56+
// Mastodon tokens don't expire
57+
default => null,
58+
};
59+
}
60+
61+
private function refreshLinkedInToken(SocialAccount $account): void
62+
{
63+
if (! $account->refresh_token) {
64+
throw new TokenExpiredException('No refresh token available for LinkedIn account');
65+
}
66+
67+
$response = Http::asForm()->post('https://www.linkedin.com/oauth/v2/accessToken', [
68+
'grant_type' => 'refresh_token',
69+
'refresh_token' => $account->refresh_token,
70+
'client_id' => config('services.linkedin.client_id'),
71+
'client_secret' => config('services.linkedin.client_secret'),
72+
]);
73+
74+
if ($response->failed()) {
75+
Log::error('ConnectionVerifier: LinkedIn token refresh failed', ['body' => $response->body()]);
76+
throw new TokenExpiredException('Failed to refresh LinkedIn token');
77+
}
78+
79+
$data = $response->json();
80+
81+
$account->update([
82+
'access_token' => $data['access_token'],
83+
'refresh_token' => $data['refresh_token'] ?? $account->refresh_token,
84+
'token_expires_at' => isset($data['expires_in']) ? now()->addSeconds($data['expires_in']) : null,
85+
]);
86+
87+
$account->refresh();
88+
}
89+
90+
private function refreshXToken(SocialAccount $account): void
91+
{
92+
if (! $account->refresh_token) {
93+
throw new TokenExpiredException('No refresh token available for X account');
94+
}
95+
96+
$response = Http::asForm()
97+
->withBasicAuth(config('services.x.client_id'), config('services.x.client_secret'))
98+
->post('https://api.x.com/2/oauth2/token', [
99+
'grant_type' => 'refresh_token',
100+
'refresh_token' => $account->refresh_token,
101+
]);
102+
103+
if ($response->failed()) {
104+
Log::error('ConnectionVerifier: X token refresh failed', ['body' => $response->body()]);
105+
throw new TokenExpiredException('Failed to refresh X token');
106+
}
107+
108+
$data = $response->json();
109+
110+
$account->update([
111+
'access_token' => $data['access_token'],
112+
'refresh_token' => $data['refresh_token'] ?? $account->refresh_token,
113+
'token_expires_at' => now()->addSeconds($data['expires_in'] ?? 7200),
114+
]);
115+
116+
$account->refresh();
117+
}
118+
119+
private function refreshBlueskyToken(SocialAccount $account): void
120+
{
121+
$service = $account->meta['service'] ?? 'https://bsky.social';
122+
123+
// Try refresh token first
124+
$response = Http::withToken($account->refresh_token)
125+
->post("{$service}/xrpc/com.atproto.server.refreshSession");
126+
127+
if ($response->successful()) {
128+
$data = $response->json();
129+
$account->update([
130+
'access_token' => $data['accessJwt'],
131+
'refresh_token' => $data['refreshJwt'],
132+
'token_expires_at' => now()->addHours(2),
133+
]);
134+
135+
$account->refresh();
136+
137+
return;
138+
}
139+
140+
// If refresh fails, re-authenticate with stored credentials
141+
if (isset($account->meta['password'])) {
142+
try {
143+
$password = decrypt($account->meta['password']);
144+
$identifier = $account->meta['identifier'];
145+
146+
$response = Http::post("{$service}/xrpc/com.atproto.server.createSession", [
147+
'identifier' => $identifier,
148+
'password' => $password,
149+
]);
150+
151+
if ($response->successful()) {
152+
$data = $response->json();
153+
$account->update([
154+
'access_token' => $data['accessJwt'],
155+
'refresh_token' => $data['refreshJwt'],
156+
'token_expires_at' => now()->addHours(2),
157+
]);
158+
159+
$account->refresh();
160+
161+
return;
162+
}
163+
} catch (\Exception $e) {
164+
Log::error('ConnectionVerifier: Bluesky re-authentication failed', [
165+
'error' => $e->getMessage(),
166+
]);
167+
}
168+
}
169+
170+
throw new TokenExpiredException('Bluesky session expired');
171+
}
172+
173+
private function refreshYouTubeToken(SocialAccount $account): void
174+
{
175+
if (! $account->refresh_token) {
176+
throw new TokenExpiredException('No refresh token available for YouTube account');
177+
}
178+
179+
$response = Http::asForm()->post('https://oauth2.googleapis.com/token', [
180+
'grant_type' => 'refresh_token',
181+
'refresh_token' => $account->refresh_token,
182+
'client_id' => config('services.youtube.client_id'),
183+
'client_secret' => config('services.youtube.client_secret'),
184+
]);
185+
186+
if ($response->failed()) {
187+
Log::error('ConnectionVerifier: YouTube token refresh failed', ['body' => $response->body()]);
188+
throw new TokenExpiredException('Failed to refresh YouTube token');
189+
}
190+
191+
$data = $response->json();
192+
193+
$account->update([
194+
'access_token' => $data['access_token'],
195+
'token_expires_at' => isset($data['expires_in']) ? now()->addSeconds($data['expires_in']) : null,
196+
]);
197+
198+
$account->refresh();
199+
}
200+
201+
private function refreshTikTokToken(SocialAccount $account): void
202+
{
203+
if (! $account->refresh_token) {
204+
throw new TokenExpiredException('No refresh token available for TikTok account');
205+
}
206+
207+
$response = Http::asForm()->post('https://open.tiktokapis.com/v2/oauth/token/', [
208+
'grant_type' => 'refresh_token',
209+
'refresh_token' => $account->refresh_token,
210+
'client_key' => config('services.tiktok.client_id'),
211+
'client_secret' => config('services.tiktok.client_secret'),
212+
]);
213+
214+
if ($response->failed()) {
215+
Log::error('ConnectionVerifier: TikTok token refresh failed', ['body' => $response->body()]);
216+
throw new TokenExpiredException('Failed to refresh TikTok token');
217+
}
218+
219+
$data = $response->json();
220+
221+
$account->update([
222+
'access_token' => $data['access_token'],
223+
'refresh_token' => $data['refresh_token'] ?? $account->refresh_token,
224+
'token_expires_at' => isset($data['expires_in']) ? now()->addSeconds($data['expires_in']) : null,
225+
]);
226+
227+
$account->refresh();
228+
}
229+
230+
private function refreshPinterestToken(SocialAccount $account): void
231+
{
232+
if (! $account->refresh_token) {
233+
throw new TokenExpiredException('No refresh token available for Pinterest account');
234+
}
235+
236+
$credentials = base64_encode(config('services.pinterest.client_id').':'.config('services.pinterest.client_secret'));
237+
238+
$response = Http::withHeaders([
239+
'Authorization' => "Basic {$credentials}",
240+
'Content-Type' => 'application/x-www-form-urlencoded',
241+
])->asForm()->post('https://api.pinterest.com/v5/oauth/token', [
242+
'grant_type' => 'refresh_token',
243+
'refresh_token' => $account->refresh_token,
244+
]);
245+
246+
if ($response->failed()) {
247+
Log::error('ConnectionVerifier: Pinterest token refresh failed', ['body' => $response->body()]);
248+
throw new TokenExpiredException('Failed to refresh Pinterest token');
249+
}
250+
251+
$data = $response->json();
252+
253+
$account->update([
254+
'access_token' => $data['access_token'],
255+
'refresh_token' => $data['refresh_token'] ?? $account->refresh_token,
256+
'token_expires_at' => isset($data['expires_in']) ? now()->addSeconds($data['expires_in']) : null,
257+
]);
258+
259+
$account->refresh();
260+
}
261+
262+
private function refreshThreadsToken(SocialAccount $account): void
263+
{
264+
// Threads uses long-lived tokens that can be refreshed
265+
$response = Http::get('https://graph.threads.net/refresh_access_token', [
266+
'grant_type' => 'th_refresh_token',
267+
'access_token' => $account->access_token,
268+
]);
269+
270+
if ($response->failed()) {
271+
Log::error('ConnectionVerifier: Threads token refresh failed', ['body' => $response->body()]);
272+
throw new TokenExpiredException('Failed to refresh Threads token');
273+
}
274+
275+
$data = $response->json();
276+
277+
$account->update([
278+
'access_token' => $data['access_token'],
279+
'token_expires_at' => isset($data['expires_in']) ? now()->addSeconds($data['expires_in']) : null,
280+
]);
281+
282+
$account->refresh();
283+
}
284+
34285
private function verifyLinkedIn(SocialAccount $account): bool
35286
{
36287
$response = Http::withToken($account->access_token)

resources/js/pages/posts/Calendar.vue

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -165,14 +165,14 @@ const isCurrentMonth = (day: dayjs.Dayjs): boolean => {
165165
166166
const getStatusColor = (status: string): string => {
167167
const colors: Record<string, string> = {
168-
draft: 'bg-gray-100 border-gray-300 text-gray-700',
169-
scheduled: 'bg-blue-50 border-blue-300 text-blue-700',
170-
publishing: 'bg-yellow-50 border-yellow-300 text-yellow-700',
171-
published: 'bg-green-50 border-green-300 text-green-700',
172-
partially_published: 'bg-orange-50 border-orange-300 text-orange-700',
173-
failed: 'bg-red-50 border-red-300 text-red-700',
168+
draft: 'bg-neutral-100 border-neutral-300 text-neutral-700 dark:bg-neutral-800 dark:border-neutral-600 dark:text-neutral-300',
169+
scheduled: 'bg-blue-50 border-blue-300 text-blue-700 dark:bg-blue-950 dark:border-blue-800 dark:text-blue-300',
170+
publishing: 'bg-yellow-50 border-yellow-300 text-yellow-700 dark:bg-yellow-950 dark:border-yellow-800 dark:text-yellow-300',
171+
published: 'bg-green-50 border-green-300 text-green-700 dark:bg-green-950 dark:border-green-800 dark:text-green-300',
172+
partially_published: 'bg-orange-50 border-orange-300 text-orange-700 dark:bg-orange-950 dark:border-orange-800 dark:text-orange-300',
173+
failed: 'bg-red-50 border-red-300 text-red-700 dark:bg-red-950 dark:border-red-800 dark:text-red-300',
174174
};
175-
return colors[status] || 'bg-gray-100 border-gray-300 text-gray-700';
175+
return colors[status] || 'bg-neutral-100 border-neutral-300 text-neutral-700 dark:bg-neutral-800 dark:border-neutral-600 dark:text-neutral-300';
176176
};
177177
178178
const getPlatformLogo = (platform: string): string => {
@@ -279,9 +279,9 @@ const formatTime = (scheduledAt: string): string => {
279279
<div class="flex -space-x-1 mb-1.5">
280280
<img v-for="pp in post.post_platforms.slice(0, 4)" :key="pp.id"
281281
:src="getPlatformLogo(pp.platform)" :alt="pp.platform"
282-
class="h-5 w-5 rounded-full ring-2 ring-white" />
282+
class="h-5 w-5 rounded-full ring-2 ring-background" />
283283
<span v-if="post.post_platforms.length > 4"
284-
class="flex items-center justify-center h-5 w-5 rounded-full bg-muted text-[10px] font-medium ring-2 ring-white">
284+
class="flex items-center justify-center h-5 w-5 rounded-full bg-muted text-[10px] font-medium ring-2 ring-background">
285285
+{{ post.post_platforms.length - 4 }}
286286
</span>
287287
</div>
@@ -342,9 +342,9 @@ const formatTime = (scheduledAt: string): string => {
342342
<div class="flex -space-x-1 shrink-0">
343343
<img v-for="pp in post.post_platforms.slice(0, post.post_platforms.length > 4 ? 3 : 4)"
344344
:key="pp.id" :src="getPlatformLogo(pp.platform)" :alt="pp.platform"
345-
class="h-4 w-4 rounded-full ring-1 ring-white" />
345+
class="h-4 w-4 rounded-full ring-1 ring-background" />
346346
<span v-if="post.post_platforms.length > 4"
347-
class="flex items-center justify-center h-4 w-4 rounded-full bg-muted text-[9px] font-medium ring-1 ring-white">
347+
class="flex items-center justify-center h-4 w-4 rounded-full bg-muted text-[9px] font-medium ring-1 ring-background">
348348
+{{ post.post_platforms.length - 3 }}
349349
</span>
350350
</div>

resources/js/pages/posts/Edit.vue

Lines changed: 9 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -273,7 +273,7 @@ const getPlatformIcon = (platform: string): Component => {
273273
274274
const getStatusConfig = (status: string) => {
275275
const configs: Record<string, { color: string; icon: any }> = {
276-
'draft': { color: 'bg-gray-100 text-gray-800', icon: IconClock },
276+
'draft': { color: 'bg-neutral-100 text-neutral-800', icon: IconClock },
277277
'scheduled': { color: 'bg-blue-100 text-blue-800', icon: IconClock },
278278
'publishing': { color: 'bg-yellow-100 text-yellow-800', icon: IconLoader2 },
279279
'published': { color: 'bg-green-100 text-green-800', icon: IconCircleCheck },
@@ -658,26 +658,25 @@ const deletePost = () => {
658658
</div>
659659

660660
<div v-if="selectedPlatforms.length > 0"
661-
class="bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]">
661+
class="bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-1">
662662
<TooltipProvider>
663663
<Tooltip v-for="pp in selectedPlatforms" :key="pp.id">
664664
<TooltipTrigger asChild>
665665
<button type="button" @click="activeTabId = pp.id" :class="[
666-
'relative inline-flex h-[calc(100%-1px)] items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring',
666+
'relative inline-flex items-center justify-center gap-1.5 rounded-md px-2 py-1.5 text-sm font-medium whitespace-nowrap transition-all',
667667
activeTabId === pp.id
668-
? 'bg-background text-foreground shadow-sm dark:border-input dark:bg-input/30'
669-
: 'text-foreground dark:text-muted-foreground'
668+
? 'bg-background text-foreground shadow-sm'
669+
: 'hover:bg-background/50 hover:text-foreground'
670670
]">
671-
<component :is="getPlatformIcon(pp.platform)"
672-
class="h-5 w-5 text-neutral-500 rounded-full" />
671+
<component :is="getPlatformIcon(pp.platform)" class="h-5 w-5" />
673672
<!-- Status indicator (read-only) or Validation indicator (edit mode) -->
674673
<span v-if="isReadOnly"
675674
class="absolute -top-0.5 -right-0.5 h-2 w-2 rounded-full border border-background"
676675
:class="{
677676
'bg-green-500': pp.status === 'published',
678677
'bg-red-500': pp.status === 'failed',
679678
'bg-yellow-500 animate-pulse': pp.status === 'publishing',
680-
'bg-gray-400': !['published', 'failed', 'publishing'].includes(pp.status)
679+
'bg-neutral-400': !['published', 'failed', 'publishing'].includes(pp.status)
681680
}" />
682681
<span v-else-if="contentValidation[pp.id]"
683682
class="absolute -top-0.5 -right-0.5 h-2 w-2 rounded-full border border-background"
@@ -770,7 +769,7 @@ const deletePost = () => {
770769
<div class="flex items-center justify-between p-4 rounded-lg border">
771770
<div class="flex items-center gap-3">
772771
<component :is="getPlatformIcon(activePlatform.platform)"
773-
class="h-8 w-8 text-neutral-600" />
772+
class="h-8 w-8 text-muted-foreground" />
774773
<div>
775774
<p class="font-medium">{{ activePlatform.social_account.display_name }}</p>
776775
<p class="text-sm text-muted-foreground">{{
@@ -885,7 +884,7 @@ const deletePost = () => {
885884
<button v-for="pp in post.post_platforms" :key="pp.id" type="button"
886885
@click="togglePlatform(pp.id)"
887886
class="flex items-center gap-2 px-3 py-2 rounded-lg border hover:bg-muted transition-colors">
888-
<component :is="getPlatformIcon(pp.platform)" class="h-5 w-5 text-neutral-600" />
887+
<component :is="getPlatformIcon(pp.platform)" class="h-5 w-5 text-muted-foreground" />
889888
<span class="text-sm">{{ pp.social_account.display_name }}</span>
890889
</button>
891890
</div>

0 commit comments

Comments
 (0)