refactor(social): use native Inertia for all connect popup flows

Replace the per-platform native form POST + manual CSRF + JSON/Blade
popup callback with a single Inertia mechanism:

- popupCallback() renders the accounts/PopupCallback Inertia page (notifies
  the opener + closes the popup) for both the GET OAuth callbacks and the
  selection submits. Drops the auth.social-callback Blade view, the
  expectsJson JSON branch, and useHttp/useSocialConnect on the frontend.
- Selection/credential pages (LinkedIn, Facebook, Instagram, Bluesky,
  Mastodon) use Inertia useForm: automatic CSRF + native validation errors.
- Unify the three selection screens on one row + View/Choose layout; the
  LinkedIn company tag now uses a building icon.
- Bluesky auth failures throw ValidationException (422 for XHR, redirect
  back with errors otherwise).

Controller tests updated from assertViewIs/assertViewHas to assertInertia.
This commit is contained in:
Paulo Castellano 2026-06-25 13:54:16 -03:00
parent 10b8579079
commit cd2798fd8d
35 changed files with 353 additions and 427 deletions

View file

@ -11,7 +11,7 @@
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\View\View;
use Illuminate\Validation\ValidationException;
use Inertia\Inertia;
use Inertia\Response;
@ -36,7 +36,7 @@ public function connect(Request $request): Response|RedirectResponse
]);
}
public function store(Request $request): View|RedirectResponse
public function store(Request $request): Response|RedirectResponse
{
$this->ensurePlatformEnabled();
@ -74,7 +74,7 @@ public function store(Request $request): View|RedirectResponse
$errorMessage = $body['message'];
}
return back()->withErrors(['password' => $errorMessage]);
throw ValidationException::withMessages(['password' => $errorMessage]);
}
$data = $response->json();
@ -113,13 +113,15 @@ public function store(Request $request): View|RedirectResponse
);
return $this->popupCallback(true, __('accounts.popup_callback.connected'), $this->platform->value);
} catch (ValidationException $e) {
throw $e;
} catch (\Exception $e) {
Log::error('Bluesky connection error', [
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
]);
return back()->withErrors(['password' => 'Error connecting to Bluesky. Please try again.']);
throw ValidationException::withMessages(['password' => 'Error connecting to Bluesky. Please try again.']);
}
}
}

View file

@ -7,7 +7,7 @@
use App\Enums\SocialAccount\Platform as SocialPlatform;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
use Inertia\Response as InertiaResponse;
use Symfony\Component\HttpFoundation\Response;
class DiscordController extends SocialController
@ -31,7 +31,7 @@ public function connect(Request $request): Response|RedirectResponse
return $this->redirectToProvider($request, $this->driver, config('trypost.platforms.discord.scopes'));
}
public function callback(Request $request): View
public function callback(Request $request): InertiaResponse
{
return $this->handleCallback($request, $this->platform, $this->driver);
}

View file

@ -13,8 +13,8 @@
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\View\View;
use Inertia\Inertia;
use Inertia\Response as InertiaResponse;
use Laravel\Socialite\Facades\Socialite;
use Symfony\Component\HttpFoundation\Response;
@ -58,7 +58,7 @@ public function connect(Request $request): Response|RedirectResponse
);
}
public function callback(Request $request): View|RedirectResponse
public function callback(Request $request): InertiaResponse|RedirectResponse
{
$workspaceId = session('social_connect_workspace');
@ -174,7 +174,7 @@ public function selectPage(Request $request)
]);
}
public function select(Request $request): View
public function select(Request $request): InertiaResponse
{
$request->validate([
'page_id' => 'required|string',

View file

@ -11,8 +11,8 @@
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Illuminate\View\View;
use Inertia\Inertia;
use Inertia\Response as InertiaResponse;
use Laravel\Socialite\Facades\Socialite;
use Symfony\Component\HttpFoundation\Response;
@ -53,7 +53,7 @@ public function connect(Request $request): Response|RedirectResponse
return Inertia::location($url);
}
public function callback(Request $request): View
public function callback(Request $request): InertiaResponse
{
$workspaceId = session('social_connect_workspace');

View file

@ -13,8 +13,8 @@
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\View\View;
use Inertia\Inertia;
use Inertia\Response as InertiaResponse;
use Laravel\Socialite\Facades\Socialite;
use Symfony\Component\HttpFoundation\Response;
@ -61,7 +61,7 @@ public function connect(Request $request): Response|RedirectResponse
return Inertia::location($url);
}
public function callback(Request $request): View|RedirectResponse
public function callback(Request $request): InertiaResponse|RedirectResponse
{
$workspaceId = session('social_connect_workspace');
@ -150,7 +150,7 @@ public function selectPage(Request $request)
]);
}
public function select(Request $request): View
public function select(Request $request): InertiaResponse
{
$request->validate([
'page_id' => 'required|string',
@ -193,7 +193,7 @@ public function select(Request $request): View
}
}
private function connectInstagramAccount(Workspace $workspace, array $pageData, $existingAccount): View
private function connectInstagramAccount(Workspace $workspace, array $pageData, $existingAccount): InertiaResponse
{
$avatarPath = data_get($pageData, 'ig_picture') ? uploadFromUrl(data_get($pageData, 'ig_picture')) : null;

View file

@ -15,7 +15,6 @@
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rules\Enum;
use Illuminate\View\View;
use Inertia\Inertia;
use Inertia\Response as InertiaResponse;
use Laravel\Socialite\Facades\Socialite;
@ -39,7 +38,7 @@ protected function ensurePlatformEnabled(): void
}
}
public function connect(Request $request): Response|View
public function connect(Request $request): Response|InertiaResponse
{
$this->ensurePlatformEnabled();
@ -61,7 +60,7 @@ public function connect(Request $request): Response|View
);
}
public function callback(Request $request): View|RedirectResponse
public function callback(Request $request): InertiaResponse|RedirectResponse
{
$workspaceId = session('social_connect_workspace');
@ -105,7 +104,7 @@ public function callback(Request $request): View|RedirectResponse
}
}
public function selectIdentity(Request $request): InertiaResponse|View
public function selectIdentity(Request $request): InertiaResponse
{
$pending = session('linkedin_pending');
@ -125,7 +124,7 @@ public function selectIdentity(Request $request): InertiaResponse|View
]);
}
public function select(Request $request): View
public function select(Request $request): InertiaResponse
{
$validator = Validator::make($request->all(), [
'type' => ['required', new Enum(LinkedInIdentityType::class)],

View file

@ -11,7 +11,6 @@
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\View\View;
use Inertia\Inertia;
use Inertia\Response;
use Symfony\Component\HttpFoundation\Response as SymfonyResponse;
@ -117,7 +116,7 @@ public function authorizeInstance(Request $request): SymfonyResponse|RedirectRes
/**
* Handle OAuth callback
*/
public function callback(Request $request): View
public function callback(Request $request): Response
{
$workspaceId = session('social_connect_workspace');
$savedState = session('mastodon_oauth_state');

View file

@ -10,7 +10,7 @@
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Illuminate\View\View;
use Inertia\Response as InertiaResponse;
use Laravel\Socialite\Facades\Socialite;
use Symfony\Component\HttpFoundation\Response;
@ -43,7 +43,7 @@ public function connect(Request $request): Response|RedirectResponse
return $this->redirectToProvider($request, $this->driver, $this->scopes);
}
public function callback(Request $request): View
public function callback(Request $request): InertiaResponse
{
$workspaceId = session('social_connect_workspace');

View file

@ -16,7 +16,6 @@
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Illuminate\View\View;
use Inertia\Inertia;
use Inertia\Response;
use Laravel\Socialite\Facades\Socialite;
@ -135,7 +134,7 @@ protected function handleCallback(
Request $request,
SocialPlatform $platform,
string $driver
): View {
): Response {
$workspaceId = session('social_connect_workspace');
if (! $workspaceId) {
@ -191,13 +190,15 @@ protected function forgetSocialConnectSession(): void
}
/**
* Return a view that closes the popup and notifies the parent window.
* Render the Inertia page that notifies the opener and closes the connect
* popup. Used by both the GET OAuth callbacks (a fresh popup page load) and
* the XHR selection submits (an Inertia visit that swaps to this page).
*/
protected function popupCallback(bool $success, string $message, ?string $platform = null): View
protected function popupCallback(bool $success, string $message, ?string $platform = null): Response
{
$this->forgetSocialConnectSession();
return view('auth.social-callback', [
return Inertia::render('accounts/PopupCallback', [
'success' => $success,
'message' => $message,
'platform' => $platform,

View file

@ -12,8 +12,8 @@
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\View\View;
use Inertia\Inertia;
use Inertia\Response as InertiaResponse;
use Symfony\Component\HttpFoundation\Response;
class ThreadsController extends SocialController
@ -57,7 +57,7 @@ public function connect(Request $request): Response|RedirectResponse
return Inertia::location("https://threads.net/oauth/authorize?{$params}");
}
public function callback(Request $request): View
public function callback(Request $request): InertiaResponse
{
$workspaceId = session('social_connect_workspace');
$savedState = session('threads_oauth_state');

View file

@ -10,7 +10,7 @@
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Illuminate\View\View;
use Inertia\Response as InertiaResponse;
use Laravel\Socialite\Facades\Socialite;
use Symfony\Component\HttpFoundation\Response;
@ -46,7 +46,7 @@ public function connect(Request $request): Response|RedirectResponse
return $this->redirectToProvider($request, $this->driver, $this->scopes);
}
public function callback(Request $request): View
public function callback(Request $request): InertiaResponse
{
$workspaceId = session('social_connect_workspace');

View file

@ -7,7 +7,7 @@
use App\Enums\SocialAccount\Platform as SocialPlatform;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
use Inertia\Response as InertiaResponse;
use Symfony\Component\HttpFoundation\Response;
class XController extends SocialController
@ -39,7 +39,7 @@ public function connect(Request $request): Response|RedirectResponse
return $this->redirectToProvider($request, $this->driver, $this->scopes);
}
public function callback(Request $request): View
public function callback(Request $request): InertiaResponse
{
return $this->handleCallback($request, $this->platform, $this->driver);
}

View file

@ -12,8 +12,8 @@
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\View\View;
use Inertia\Inertia;
use Inertia\Response as InertiaResponse;
use Laravel\Socialite\Facades\Socialite;
use Symfony\Component\HttpFoundation\Response;
@ -50,7 +50,7 @@ public function connect(Request $request): Response|RedirectResponse
return $this->redirectToGoogle();
}
public function callback(Request $request): View|RedirectResponse
public function callback(Request $request): InertiaResponse|RedirectResponse
{
$workspaceId = session('social_connect_workspace');
@ -168,7 +168,7 @@ public function selectChannel(Request $request)
]);
}
public function select(Request $request): View
public function select(Request $request): InertiaResponse
{
$request->validate([
'channel_id' => 'required|string',

View file

@ -83,6 +83,8 @@
'no_pages' => 'No pages found',
'no_pages_description' => 'You are not an admin of any Facebook page.',
'page_label' => 'Facebook Page',
'view' => 'View',
'choose' => 'Choose',
],
'instagram_facebook' => [
@ -90,6 +92,8 @@
'description' => 'Choose which Instagram account you want to connect',
'no_pages' => 'No Instagram accounts found',
'no_pages_description' => 'No Facebook Pages with linked Instagram Business accounts were found.',
'view' => 'View',
'choose' => 'Choose',
],
'linkedin' => [
@ -121,7 +125,6 @@
'title_success' => 'Connected',
'title_error' => 'Error',
'closing' => 'This window will close automatically...',
'close_now' => 'You can close this window now.',
'connected' => 'Account connected!',
'reconnected' => 'Account reconnected!',
'error_connecting' => 'Error connecting account. Please try again.',

View file

@ -83,6 +83,8 @@
'no_pages' => 'No se encontraron páginas',
'no_pages_description' => 'No eres administrador de ninguna página de Facebook.',
'page_label' => 'Página de Facebook',
'view' => 'Ver',
'choose' => 'Elegir',
],
'instagram_facebook' => [
@ -90,6 +92,8 @@
'description' => 'Elige qué cuenta de Instagram deseas conectar',
'no_pages' => 'No se encontraron cuentas de Instagram',
'no_pages_description' => 'No se encontraron páginas de Facebook con cuentas Instagram Business vinculadas.',
'view' => 'Ver',
'choose' => 'Elegir',
],
'linkedin' => [
@ -121,7 +125,6 @@
'title_success' => 'Conectado',
'title_error' => 'Error',
'closing' => 'Esta ventana se cerrará automáticamente...',
'close_now' => 'Puedes cerrar esta ventana ahora.',
'connected' => '¡Cuenta conectada!',
'reconnected' => '¡Cuenta reconectada!',
'error_connecting' => 'Error al conectar la cuenta. Inténtalo de nuevo.',

View file

@ -83,6 +83,8 @@
'no_pages' => 'Nenhuma página encontrada',
'no_pages_description' => 'Você não é administrador de nenhuma página do Facebook.',
'page_label' => 'Página do Facebook',
'view' => 'Ver',
'choose' => 'Escolher',
],
'instagram_facebook' => [
@ -90,6 +92,8 @@
'description' => 'Escolha qual conta do Instagram você deseja conectar',
'no_pages' => 'Nenhuma conta do Instagram encontrada',
'no_pages_description' => 'Nenhuma Página do Facebook com conta Instagram Business vinculada foi encontrada.',
'view' => 'Ver',
'choose' => 'Escolher',
],
'linkedin' => [
@ -121,7 +125,6 @@
'title_success' => 'Conectado',
'title_error' => 'Erro',
'closing' => 'Esta janela será fechada automaticamente...',
'close_now' => 'Você pode fechar esta janela agora.',
'connected' => 'Conta conectada!',
'reconnected' => 'Conta reconectada!',
'error_connecting' => 'Erro ao conectar conta. Por favor, tente novamente.',

View file

@ -1,7 +1,7 @@
<script setup lang="ts">
import { useForm } from '@inertiajs/vue3';
import { IconInfoCircle } from '@tabler/icons-vue';
import { trans } from 'laravel-vue-i18n';
import { ref } from 'vue';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Button } from '@/components/ui/button';
@ -10,56 +10,40 @@ import { Label } from '@/components/ui/label';
import PopupLayout from '@/layouts/PopupLayout.vue';
import { store as storeBluesky } from '@/routes/app/social/bluesky';
interface Props {
errors?: Record<string, string>;
}
const form = useForm({ identifier: '', password: '' });
defineProps<Props>();
const formRef = ref<HTMLFormElement | null>(null);
const identifier = ref('');
const password = ref('');
const isSubmitting = ref(false);
const submit = () => {
isSubmitting.value = true;
formRef.value?.submit();
};
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') ?? '';
const onSubmit = () => form.post(storeBluesky.url());
</script>
<template>
<PopupLayout :title="$t('accounts.bluesky.title')">
<div class="max-w-md mx-auto">
<div class="flex items-center gap-3 mb-6">
<img src="/images/accounts/bluesky.png" alt="Bluesky" class="h-12 w-12" />
<img src="/images/accounts/bluesky.png" alt="Bluesky" class="h-10 w-10" />
<div>
<h1 class="text-xl font-bold tracking-tight">{{ $t('accounts.bluesky.title') }}</h1>
<p class="text-sm text-muted-foreground">{{ $t('accounts.bluesky.description') }}</p>
</div>
</div>
<form ref="formRef" :action="storeBluesky.url()" method="POST" @submit.prevent="submit" class="space-y-4">
<input type="hidden" name="_token" :value="csrfToken" />
<form @submit.prevent="onSubmit" class="space-y-4">
<div class="space-y-2">
<Label for="identifier">{{ $t('accounts.bluesky.email') }}</Label>
<Input id="identifier" name="identifier" v-model="identifier" type="text"
:placeholder="trans('accounts.bluesky.email_placeholder')" :class="{ 'border-destructive': errors?.identifier }"
<Input id="identifier" v-model="form.identifier" type="text"
:placeholder="trans('accounts.bluesky.email_placeholder')" :class="{ 'border-destructive': form.errors.identifier }"
/>
<p v-if="errors?.identifier" class="text-sm text-destructive">
{{ errors.identifier }}
<p v-if="form.errors.identifier" class="text-sm text-destructive">
{{ form.errors.identifier }}
</p>
</div>
<div class="space-y-2">
<Label for="password">{{ $t('accounts.bluesky.app_password') }}</Label>
<Input id="password" name="password" v-model="password" type="password"
:placeholder="trans('accounts.bluesky.app_password_placeholder')" :class="{ 'border-destructive': errors?.password }"
<Input id="password" v-model="form.password" type="password"
:placeholder="trans('accounts.bluesky.app_password_placeholder')" :class="{ 'border-destructive': form.errors.password }"
/>
<p v-if="errors?.password" class="text-sm text-destructive">
{{ errors.password }}
<p v-if="form.errors.password" class="text-sm text-destructive">
{{ form.errors.password }}
</p>
</div>
@ -70,10 +54,10 @@ const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribut
</AlertDescription>
</Alert>
<Button type="submit" :disabled="isSubmitting" class="w-full">
{{ isSubmitting ? $t('accounts.bluesky.submitting') : $t('accounts.bluesky.submit') }}
<Button type="submit" :disabled="form.processing" class="w-full">
{{ form.processing ? $t('accounts.bluesky.submitting') : $t('accounts.bluesky.submit') }}
</Button>
</form>
</div>
</PopupLayout>
</template>
</template>

View file

@ -1,9 +1,9 @@
<script setup lang="ts">
import { IconBrandFacebook, IconCheck } from '@tabler/icons-vue';
import { ref } from 'vue';
import { useForm } from '@inertiajs/vue3';
import { IconBrandFacebook, IconExternalLink } from '@tabler/icons-vue';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Button } from '@/components/ui/button';
import PopupLayout from '@/layouts/PopupLayout.vue';
import { select as selectFacebookPage } from '@/routes/app/social/facebook';
@ -23,30 +23,29 @@ interface Workspace {
interface Props {
workspace: Workspace;
pages: Page[];
error?: string;
}
defineProps<Props>();
const formRef = ref<HTMLFormElement | null>(null);
const selectedPageId = ref<string | null>(null);
const form = useForm({ page_id: '' });
const handleSelectPage = (page: Page) => {
selectedPageId.value = page.id;
setTimeout(() => formRef.value?.submit(), 0);
form.page_id = page.id;
form.post(selectFacebookPage.url());
};
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') ?? '';
const openExternal = (url: string | null) => {
if (url) {
window.open(url, '_blank', 'noopener');
}
};
const pageUrl = (username: string | null): string | null =>
username ? `https://www.facebook.com/${username}` : null;
</script>
<template>
<PopupLayout :title="$t('accounts.facebook.title')">
<!-- Hidden form for regular POST submission -->
<form ref="formRef" :action="selectFacebookPage.url()" method="POST" class="hidden">
<input type="hidden" name="_token" :value="csrfToken" />
<input type="hidden" name="page_id" :value="selectedPageId" />
</form>
<div class="flex flex-col gap-6">
<div class="flex items-center gap-3">
<img src="/images/accounts/facebook.png" alt="Facebook" class="h-10 w-10" />
@ -56,11 +55,7 @@ const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribut
</div>
</div>
<Alert v-if="error" variant="destructive">
<AlertDescription>{{ error }}</AlertDescription>
</Alert>
<div v-if="pages.length === 0 && !error" class="text-center py-12">
<div v-if="pages.length === 0" class="py-12 text-center">
<div class="mx-auto flex h-14 w-14 items-center justify-center rounded-full bg-muted">
<IconBrandFacebook class="h-7 w-7 text-muted-foreground" />
</div>
@ -71,37 +66,40 @@ const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribut
</div>
<div v-else class="grid gap-3">
<button
<div
v-for="page in pages"
:key="page.id"
@click="handleSelectPage(page)"
class="group relative overflow-hidden rounded-lg border bg-card p-4 text-left transition-all hover:border-primary hover:shadow-md focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2"
class="flex items-center gap-4 rounded-lg border bg-card p-4"
dusk="facebook-page"
>
<div class="flex items-center gap-4">
<Avatar class="h-12 w-12 rounded-lg">
<AvatarImage v-if="page.picture" :src="page.picture" class="object-cover" />
<AvatarFallback class="rounded-lg bg-blue-100 dark:bg-blue-900">
<IconBrandFacebook class="h-6 w-6 text-blue-600 dark:text-blue-400" />
</AvatarFallback>
</Avatar>
<div class="flex-1 min-w-0">
<h3 class="font-semibold truncate group-hover:text-primary transition-colors">
{{ page.name }}
</h3>
<p v-if="page.username" class="text-sm text-muted-foreground truncate">
facebook.com/{{ page.username }}
</p>
<p v-else class="text-sm text-muted-foreground">{{ $t('accounts.facebook.page_label') }}</p>
</div>
<div class="shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
<div
class="flex h-8 w-8 items-center justify-center rounded-full bg-primary text-primary-foreground"
>
<IconCheck class="h-4 w-4" />
</div>
</div>
<Avatar class="h-12 w-12 rounded-lg">
<AvatarImage v-if="page.picture" :src="page.picture" class="object-cover" />
<AvatarFallback class="rounded-lg bg-blue-100 dark:bg-blue-900">
<IconBrandFacebook class="h-6 w-6 text-blue-600 dark:text-blue-400" />
</AvatarFallback>
</Avatar>
<div class="min-w-0 flex-1">
<h3 class="truncate font-semibold">{{ page.name }}</h3>
<p v-if="page.username" class="truncate text-sm text-muted-foreground">
facebook.com/{{ page.username }}
</p>
<p v-else class="truncate text-sm text-muted-foreground">{{ $t('accounts.facebook.page_label') }}</p>
</div>
</button>
<div class="flex shrink-0 items-center gap-2">
<Button
v-if="pageUrl(page.username)"
variant="ghost"
size="sm"
@click="openExternal(pageUrl(page.username))"
>
<IconExternalLink class="h-4 w-4" />
{{ $t('accounts.facebook.view') }}
</Button>
<Button size="sm" dusk="choose-facebook-page" :disabled="form.processing" @click="handleSelectPage(page)">
{{ $t('accounts.facebook.choose') }}
</Button>
</div>
</div>
</div>
</div>
</PopupLayout>

View file

@ -1,9 +1,9 @@
<script setup lang="ts">
import { IconBrandInstagram, IconCheck } from '@tabler/icons-vue';
import { ref } from 'vue';
import { useForm } from '@inertiajs/vue3';
import { IconBrandInstagram, IconExternalLink } from '@tabler/icons-vue';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Button } from '@/components/ui/button';
import PopupLayout from '@/layouts/PopupLayout.vue';
import { select as selectPage } from '@/routes/app/social/instagram-facebook';
@ -25,29 +25,28 @@ interface Workspace {
interface Props {
workspace: Workspace;
pages: Page[];
error?: string;
}
defineProps<Props>();
const formRef = ref<HTMLFormElement | null>(null);
const selectedPageId = ref<string | null>(null);
const form = useForm({ page_id: '' });
const handleSelectPage = (page: Page) => {
selectedPageId.value = page.page_id;
setTimeout(() => formRef.value?.submit(), 0);
form.page_id = page.page_id;
form.post(selectPage.url());
};
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') ?? '';
const openExternal = (url: string | null) => {
if (url) {
window.open(url, '_blank', 'noopener');
}
};
const igUrl = (username: string): string => `https://www.instagram.com/${username}`;
</script>
<template>
<PopupLayout :title="$t('accounts.instagram_facebook.title')">
<form ref="formRef" :action="selectPage.url()" method="POST" class="hidden">
<input type="hidden" name="_token" :value="csrfToken" />
<input type="hidden" name="page_id" :value="selectedPageId" />
</form>
<div class="flex flex-col gap-6">
<div class="flex items-center gap-3">
<img src="/images/accounts/instagram.png" alt="Instagram" class="h-10 w-10" />
@ -57,11 +56,7 @@ const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribut
</div>
</div>
<Alert v-if="error" variant="destructive">
<AlertDescription>{{ error }}</AlertDescription>
</Alert>
<div v-if="pages.length === 0 && !error" class="text-center py-12">
<div v-if="pages.length === 0" class="py-12 text-center">
<div class="mx-auto flex h-14 w-14 items-center justify-center rounded-full bg-muted">
<IconBrandInstagram class="h-7 w-7 text-muted-foreground" />
</div>
@ -72,34 +67,36 @@ const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribut
</div>
<div v-else class="grid gap-3">
<button
<div
v-for="page in pages"
:key="page.ig_id"
@click="handleSelectPage(page)"
class="group relative overflow-hidden rounded-lg border bg-card p-4 text-left transition-all hover:border-primary hover:shadow-md focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2"
class="flex items-center gap-4 rounded-lg border bg-card p-4"
dusk="instagram-page"
>
<div class="flex items-center gap-4">
<Avatar class="h-12 w-12 rounded-lg">
<AvatarImage v-if="page.ig_picture" :src="page.ig_picture" class="object-cover" />
<AvatarFallback class="rounded-lg bg-pink-100 dark:bg-pink-900">
<IconBrandInstagram class="h-6 w-6 text-pink-600 dark:text-pink-400" />
</AvatarFallback>
</Avatar>
<div class="flex-1 min-w-0">
<h3 class="font-semibold truncate group-hover:text-primary transition-colors">
@{{ page.ig_username }}
</h3>
<p class="text-sm text-muted-foreground truncate">
{{ page.page_name }}
</p>
</div>
<div class="shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
<div class="flex h-8 w-8 items-center justify-center rounded-full bg-primary text-primary-foreground">
<IconCheck class="h-4 w-4" />
</div>
</div>
<Avatar class="h-12 w-12 rounded-lg">
<AvatarImage v-if="page.ig_picture" :src="page.ig_picture" class="object-cover" />
<AvatarFallback class="rounded-lg bg-pink-100 dark:bg-pink-900">
<IconBrandInstagram class="h-6 w-6 text-pink-600 dark:text-pink-400" />
</AvatarFallback>
</Avatar>
<div class="min-w-0 flex-1">
<h3 class="truncate font-semibold">@{{ page.ig_username }}</h3>
<p class="truncate text-sm text-muted-foreground">{{ page.page_name }}</p>
</div>
</button>
<div class="flex shrink-0 items-center gap-2">
<Button
variant="ghost"
size="sm"
@click="openExternal(igUrl(page.ig_username))"
>
<IconExternalLink class="h-4 w-4" />
{{ $t('accounts.instagram_facebook.view') }}
</Button>
<Button size="sm" dusk="choose-instagram-page" :disabled="form.processing" @click="handleSelectPage(page)">
{{ $t('accounts.instagram_facebook.choose') }}
</Button>
</div>
</div>
</div>
</div>
</PopupLayout>

View file

@ -1,11 +1,11 @@
<script setup lang="ts">
import { useForm } from '@inertiajs/vue3';
import {
IconBuilding,
IconExternalLink,
IconUser,
IconUsers,
} from '@tabler/icons-vue';
import { computed, ref } from 'vue';
import { computed } from 'vue';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Button } from '@/components/ui/button';
@ -31,33 +31,22 @@ const props = defineProps<{
organizations: Organization[];
}>();
const form = useForm({ type: 'person', organization_id: '' });
const isEmpty = computed(
() => !props.person && props.organizations.length === 0,
);
const formRef = ref<HTMLFormElement | null>(null);
const selectedType = ref<'person' | 'organization'>('person');
const selectedOrg = ref<Organization | null>(null);
const csrfToken =
document
.querySelector('meta[name="csrf-token"]')
?.getAttribute('content') ?? '';
const submit = () => {
setTimeout(() => formRef.value?.submit(), 0);
};
const choosePerson = () => {
selectedType.value = 'person';
selectedOrg.value = null;
submit();
form.type = 'person';
form.organization_id = '';
form.post(selectLinkedIn.url());
};
const chooseOrganization = (org: Organization) => {
selectedType.value = 'organization';
selectedOrg.value = org;
submit();
form.type = 'organization';
form.organization_id = org.id;
form.post(selectLinkedIn.url());
};
const openExternal = (url: string | null) => {
@ -75,12 +64,6 @@ const organizationUrl = (vanity: string | null): string | null =>
<template>
<PopupLayout :title="$t('accounts.linkedin.select_title')">
<form ref="formRef" :action="selectLinkedIn.url()" method="POST" class="hidden">
<input type="hidden" name="_token" :value="csrfToken" />
<input type="hidden" name="type" :value="selectedType" />
<input type="hidden" name="organization_id" :value="selectedOrg?.id ?? ''" />
</form>
<div class="flex flex-col gap-6">
<div class="flex items-center gap-3">
<img src="/images/accounts/linkedin.png" alt="LinkedIn" class="h-10 w-10" />
@ -140,7 +123,7 @@ const organizationUrl = (vanity: string | null): string | null =>
<IconExternalLink class="h-4 w-4" />
{{ $t('accounts.linkedin.view') }}
</Button>
<Button size="sm" dusk="choose-person" @click="choosePerson">
<Button size="sm" dusk="choose-person" :disabled="form.processing" @click="choosePerson">
{{ $t('accounts.linkedin.choose') }}
</Button>
</div>
@ -164,7 +147,7 @@ const organizationUrl = (vanity: string | null): string | null =>
<span
class="inline-flex shrink-0 items-center gap-1 rounded-full bg-muted px-2 py-0.5 text-xs font-medium text-muted-foreground"
>
<IconUsers class="h-3 w-3" />
<IconBuilding class="h-3 w-3" />
{{ $t('accounts.linkedin.organization_tag') }}
</span>
</div>
@ -182,7 +165,7 @@ const organizationUrl = (vanity: string | null): string | null =>
<IconExternalLink class="h-4 w-4" />
{{ $t('accounts.linkedin.view') }}
</Button>
<Button size="sm" dusk="choose-organization" @click="chooseOrganization(org)">
<Button size="sm" dusk="choose-organization" :disabled="form.processing" @click="chooseOrganization(org)">
{{ $t('accounts.linkedin.choose') }}
</Button>
</div>

View file

@ -1,7 +1,7 @@
<script setup lang="ts">
import { useForm } from '@inertiajs/vue3';
import { IconInfoCircle } from '@tabler/icons-vue';
import { trans } from 'laravel-vue-i18n';
import { ref } from 'vue';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Button } from '@/components/ui/button';
@ -10,56 +10,34 @@ import { Label } from '@/components/ui/label';
import PopupLayout from '@/layouts/PopupLayout.vue';
import { authorize as authorizeMastodon } from '@/routes/app/social/mastodon';
interface Props {
errors?: Record<string, string>;
}
const form = useForm({ instance: 'https://mastodon.social' });
defineProps<Props>();
const formRef = ref<HTMLFormElement | null>(null);
const instance = ref('https://mastodon.social');
const isSubmitting = ref(false);
const submit = () => {
isSubmitting.value = true;
formRef.value?.submit();
};
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') ?? '';
const onSubmit = () => form.post(authorizeMastodon.url());
</script>
<template>
<PopupLayout :title="$t('accounts.mastodon.title')">
<div class="max-w-md mx-auto">
<div class="flex items-center gap-3 mb-6">
<img src="/images/accounts/mastodon.png" alt="Mastodon" class="h-12 w-12" />
<img src="/images/accounts/mastodon.png" alt="Mastodon" class="h-10 w-10" />
<div>
<h1 class="text-xl font-bold tracking-tight">{{ $t('accounts.mastodon.title') }}</h1>
<p class="text-sm text-muted-foreground">{{ $t('accounts.mastodon.description') }}</p>
</div>
</div>
<form
ref="formRef"
:action="authorizeMastodon.url()"
method="POST"
@submit.prevent="submit"
class="space-y-4"
>
<input type="hidden" name="_token" :value="csrfToken" />
<form @submit.prevent="onSubmit" class="space-y-4">
<div class="space-y-2">
<Label for="instance">{{ $t('accounts.mastodon.instance_url') }}</Label>
<Input
id="instance"
name="instance"
v-model="instance"
v-model="form.instance"
type="url"
:placeholder="trans('accounts.mastodon.instance_placeholder')"
:class="{ 'border-destructive': errors?.instance }"
:class="{ 'border-destructive': form.errors.instance }"
/>
<p v-if="errors?.instance" class="text-sm text-destructive">
{{ errors.instance }}
<p v-if="form.errors.instance" class="text-sm text-destructive">
{{ form.errors.instance }}
</p>
</div>
@ -68,8 +46,8 @@ const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribut
<AlertDescription class="inline">{{ $t('accounts.mastodon.instance_hint') }}</AlertDescription>
</Alert>
<Button type="submit" :disabled="isSubmitting" class="w-full">
{{ isSubmitting ? $t('accounts.mastodon.submitting') : $t('accounts.mastodon.submit') }}
<Button type="submit" :disabled="form.processing" class="w-full">
{{ form.processing ? $t('accounts.mastodon.submitting') : $t('accounts.mastodon.submit') }}
</Button>
</form>
</div>

View file

@ -0,0 +1,54 @@
<script setup lang="ts">
import { IconCircleCheck, IconCircleX } from '@tabler/icons-vue';
import { onMounted } from 'vue';
import PopupLayout from '@/layouts/PopupLayout.vue';
const props = defineProps<{
success: boolean;
message: string;
platform?: string | null;
}>();
const CLOSE_DELAY = 1500;
onMounted(() => {
if (window.opener && !window.opener.closed) {
try {
window.opener.postMessage(
{
type: 'social-oauth-callback',
success: props.success,
message: props.message,
platform: props.platform ?? null,
},
window.location.origin,
);
} catch {
// Opener may be cross-origin or already gone; the timed close still applies.
}
}
window.setTimeout(() => window.close(), CLOSE_DELAY);
});
</script>
<template>
<PopupLayout :title="success ? $t('accounts.popup_callback.title_success') : $t('accounts.popup_callback.title_error')">
<div class="flex flex-col items-center justify-center gap-3 py-16 text-center" dusk="popup-callback">
<div
class="flex h-14 w-14 items-center justify-center rounded-full"
:class="
success
? 'bg-green-100 text-green-600 dark:bg-green-900 dark:text-green-400'
: 'bg-red-100 text-red-600 dark:bg-red-900 dark:text-red-400'
"
>
<IconCircleCheck v-if="success" class="h-7 w-7" />
<IconCircleX v-else class="h-7 w-7" />
</div>
<p class="text-lg font-medium text-foreground">{{ message }}</p>
<p class="text-sm text-muted-foreground">{{ $t('accounts.popup_callback.closing') }}</p>
</div>
</PopupLayout>
</template>

View file

@ -1,89 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<title>{{ $success ? __('accounts.popup_callback.title_success') : __('accounts.popup_callback.title_error') }}</title>
<style>
body {
font-family: system-ui, -apple-system, sans-serif;
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
margin: 0;
background: #f9fafb;
}
.container {
text-align: center;
padding: 2rem;
}
.icon {
font-size: 3rem;
margin-bottom: 1rem;
}
.message {
color: #374151;
font-size: 1.125rem;
}
.submessage {
color: #6b7280;
font-size: 0.875rem;
margin-top: 0.5rem;
}
</style>
</head>
<body>
<div class="container">
<div class="icon">{{ $success ? '✓' : '✕' }}</div>
<div class="message">{{ $message }}</div>
<div class="submessage">{{ __('accounts.popup_callback.closing') }}</div>
</div>
<script>
(function() {
const result = {
success: {{ $success ? 'true' : 'false' }},
message: @json($message),
platform: @json($platform ?? null)
};
// Try to notify the parent window
if (window.opener && !window.opener.closed) {
try {
window.opener.postMessage({
type: 'social-oauth-callback',
...result
}, window.location.origin);
} catch (e) {
// If postMessage fails, just reload the opener
try {
window.opener.location.reload();
} catch (e2) {}
}
}
// Try to close immediately and with delay
function closeWindow() {
try {
window.open('', '_self', '');
window.close();
} catch (e) {}
// Fallback: try again
try {
window.close();
} catch (e) {}
}
// Close after a short delay
setTimeout(closeWindow, 2000);
// If still open after 2 seconds, show manual close message
setTimeout(function() {
if (!window.closed) {
document.querySelector('.submessage').textContent = @json(__('accounts.popup_callback.close_now'));
}
}, 2000);
})();
</script>
</body>
</html>

View file

@ -9,6 +9,7 @@
use App\Models\User;
use App\Models\Workspace;
use Illuminate\Support\Facades\Http;
use Inertia\Testing\AssertableInertia;
beforeEach(function () {
$this->user = User::factory()->create();
@ -45,8 +46,8 @@
]);
$response->assertOk();
$response->assertViewIs('auth.social-callback');
$response->assertViewHas('success', true);
$response->assertInertia(fn (AssertableInertia $page) => $page->component('accounts/PopupCallback'));
$response->assertInertia(fn (AssertableInertia $page) => $page->where('success', true));
$this->assertDatabaseHas('social_accounts', [
'workspace_id' => $this->workspace->id,
@ -107,7 +108,7 @@
]);
$response->assertOk();
$response->assertViewHas('success', true);
$response->assertInertia(fn (AssertableInertia $page) => $page->where('success', true));
expect($this->workspace->socialAccounts()->where('platform', Platform::Bluesky)->count())->toBe(2);
});

View file

@ -7,6 +7,7 @@
use App\Enums\UserWorkspace\Role;
use App\Models\User;
use App\Models\Workspace;
use Inertia\Testing\AssertableInertia;
use Laravel\Socialite\Facades\Socialite;
use Laravel\Socialite\Two\User as SocialiteUser;
@ -52,7 +53,7 @@
$response = $this->actingAs($this->user)->get(route('app.social.discord.callback'));
$response->assertOk();
$response->assertViewHas('success', true);
$response->assertInertia(fn (AssertableInertia $page) => $page->where('success', true));
$this->assertDatabaseHas('social_accounts', [
'workspace_id' => $this->workspace->id,
@ -74,7 +75,7 @@
$response = $this->actingAs($this->user)->get(route('app.social.discord.callback'));
$response->assertOk();
$response->assertViewHas('success', false);
$response->assertInertia(fn (AssertableInertia $page) => $page->where('success', false));
expect($this->workspace->socialAccounts()->where('platform', Platform::Discord)->count())->toBe(0);
});

View file

@ -9,6 +9,7 @@
use App\Models\User;
use App\Models\Workspace;
use Illuminate\Support\Facades\Http;
use Inertia\Testing\AssertableInertia;
use Laravel\Socialite\Facades\Socialite;
use Laravel\Socialite\Two\User as SocialiteUser;
@ -70,8 +71,8 @@
$response = $this->actingAs($this->user)->get(route('app.social.facebook.callback'));
$response->assertOk();
$response->assertViewIs('auth.social-callback');
$response->assertViewHas('success', true);
$response->assertInertia(fn (AssertableInertia $page) => $page->component('accounts/PopupCallback'));
$response->assertInertia(fn (AssertableInertia $page) => $page->where('success', true));
$this->assertDatabaseHas('social_accounts', [
'workspace_id' => $this->workspace->id,
@ -119,9 +120,9 @@
$response = $this->actingAs($this->user)->get(route('app.social.facebook.callback'));
$response->assertOk();
$response->assertViewIs('auth.social-callback');
$response->assertViewHas('success', false);
$response->assertViewHas('message', __('accounts.popup_callback.network_taken'));
$response->assertInertia(fn (AssertableInertia $page) => $page->component('accounts/PopupCallback'));
$response->assertInertia(fn (AssertableInertia $page) => $page->where('success', false));
$response->assertInertia(fn (AssertableInertia $page) => $page->where('message', __('accounts.popup_callback.network_taken')));
// The duplicate was blocked by the observer — only the pre-existing account remains.
expect($this->workspace->socialAccounts()->where('platform', Platform::Facebook)->count())->toBe(1);
@ -189,8 +190,8 @@
$response = $this->actingAs($this->user)->get(route('app.social.facebook.callback'));
$response->assertOk();
$response->assertViewHas('success', false);
$response->assertViewHas('message', 'No Facebook Pages found. You need to be an admin of at least one page.');
$response->assertInertia(fn (AssertableInertia $page) => $page->where('success', false));
$response->assertInertia(fn (AssertableInertia $page) => $page->where('message', 'No Facebook Pages found. You need to be an admin of at least one page.'));
});
test('facebook callback fails with expired session', function () {
@ -199,8 +200,8 @@
$response = $this->actingAs($this->user)->get(route('app.social.facebook.callback'));
$response->assertOk();
$response->assertViewHas('success', false);
$response->assertViewHas('message', 'Session expired. Please try again.');
$response->assertInertia(fn (AssertableInertia $page) => $page->where('success', false));
$response->assertInertia(fn (AssertableInertia $page) => $page->where('message', 'Session expired. Please try again.'));
});
test('user can connect multiple facebook accounts in self-hosted mode', function () {
@ -239,7 +240,7 @@
$response = $this->actingAs($this->user)->get(route('app.social.facebook.callback'));
$response->assertOk();
$response->assertViewHas('success', true);
$response->assertInertia(fn (AssertableInertia $page) => $page->where('success', true));
expect($this->workspace->socialAccounts()->where('platform', Platform::Facebook)->count())->toBe(2);
});
@ -259,8 +260,8 @@
$response = $this->actingAs($this->user)->get(route('app.social.facebook.callback'));
$response->assertOk();
$response->assertViewHas('success', false);
$response->assertViewHas('message', 'Error connecting account. Please try again.');
$response->assertInertia(fn (AssertableInertia $page) => $page->where('success', false));
$response->assertInertia(fn (AssertableInertia $page) => $page->where('message', 'Error connecting account. Please try again.'));
});
test('facebook page selection creates account', function () {
@ -293,7 +294,7 @@
]);
$response->assertOk();
$response->assertViewHas('success', true);
$response->assertInertia(fn (AssertableInertia $page) => $page->where('success', true));
$this->assertDatabaseHas('social_accounts', [
'workspace_id' => $this->workspace->id,
@ -311,8 +312,8 @@
]);
$response->assertOk();
$response->assertViewHas('success', false);
$response->assertViewHas('message', 'Session expired. Please try again.');
$response->assertInertia(fn (AssertableInertia $page) => $page->where('success', false));
$response->assertInertia(fn (AssertableInertia $page) => $page->where('message', 'Session expired. Please try again.'));
});
test('facebook page selection fails with invalid page id', function () {
@ -337,6 +338,6 @@
]);
$response->assertOk();
$response->assertViewHas('success', false);
$response->assertViewHas('message', 'Page not found.');
$response->assertInertia(fn (AssertableInertia $page) => $page->where('success', false));
$response->assertInertia(fn (AssertableInertia $page) => $page->where('message', 'Page not found.'));
});

View file

@ -8,6 +8,7 @@
use App\Models\SocialAccount;
use App\Models\User;
use App\Models\Workspace;
use Inertia\Testing\AssertableInertia;
use Laravel\Socialite\Facades\Socialite;
use Laravel\Socialite\Two\User as SocialiteUser;
@ -62,8 +63,8 @@
$response = $this->actingAs($this->user)->get(route('app.social.instagram.callback'));
$response->assertOk();
$response->assertViewIs('auth.social-callback');
$response->assertViewHas('success', true);
$response->assertInertia(fn (AssertableInertia $page) => $page->component('accounts/PopupCallback'));
$response->assertInertia(fn (AssertableInertia $page) => $page->where('success', true));
$this->assertDatabaseHas('social_accounts', [
'workspace_id' => $this->workspace->id,
@ -78,8 +79,8 @@
$response = $this->actingAs($this->user)->get(route('app.social.instagram.callback'));
$response->assertOk();
$response->assertViewHas('success', false);
$response->assertViewHas('message', 'Session expired. Please try again.');
$response->assertInertia(fn (AssertableInertia $page) => $page->where('success', false));
$response->assertInertia(fn (AssertableInertia $page) => $page->where('message', 'Session expired. Please try again.'));
});
test('user can connect multiple instagram accounts in self-hosted mode', function () {
@ -115,7 +116,7 @@
$response = $this->actingAs($this->user)->get(route('app.social.instagram.callback'));
$response->assertOk();
$response->assertViewHas('success', true);
$response->assertInertia(fn (AssertableInertia $page) => $page->where('success', true));
expect($this->workspace->socialAccounts()->where('platform', Platform::Instagram)->count())->toBe(2);
});
@ -150,9 +151,9 @@
$response = $this->actingAs($this->user)->get(route('app.social.instagram.callback'));
$response->assertOk();
$response->assertViewIs('auth.social-callback');
$response->assertViewHas('success', false);
$response->assertViewHas('message', __('accounts.popup_callback.network_taken'));
$response->assertInertia(fn (AssertableInertia $page) => $page->component('accounts/PopupCallback'));
$response->assertInertia(fn (AssertableInertia $page) => $page->where('success', false));
$response->assertInertia(fn (AssertableInertia $page) => $page->where('message', __('accounts.popup_callback.network_taken')));
expect($this->workspace->socialAccounts()->where('platform', Platform::Instagram)->count())->toBe(1);
});
@ -172,8 +173,8 @@
$response = $this->actingAs($this->user)->get(route('app.social.instagram.callback'));
$response->assertOk();
$response->assertViewHas('success', false);
$response->assertViewHas('message', 'Error connecting account. Please try again.');
$response->assertInertia(fn (AssertableInertia $page) => $page->where('success', false));
$response->assertInertia(fn (AssertableInertia $page) => $page->where('message', 'Error connecting account. Please try again.'));
});
test('instagram connect redirects to create workspace if none exists', function () {

View file

@ -7,6 +7,7 @@
use App\Models\SocialAccount;
use App\Models\User;
use App\Models\Workspace;
use Inertia\Testing\AssertableInertia;
beforeEach(function () {
$this->user = User::factory()->create();
@ -42,8 +43,8 @@
]);
$response->assertOk();
$response->assertViewIs('auth.social-callback');
$response->assertViewHas('success', true);
$response->assertInertia(fn (AssertableInertia $page) => $page->component('accounts/PopupCallback'));
$response->assertInertia(fn (AssertableInertia $page) => $page->where('success', true));
$this->assertDatabaseHas('social_accounts', [
'workspace_id' => $this->workspace->id,
@ -86,9 +87,9 @@
]);
$response->assertOk();
$response->assertViewIs('auth.social-callback');
$response->assertViewHas('success', false);
$response->assertViewHas('message', __('accounts.popup_callback.network_taken'));
$response->assertInertia(fn (AssertableInertia $page) => $page->component('accounts/PopupCallback'));
$response->assertInertia(fn (AssertableInertia $page) => $page->where('success', false));
$response->assertInertia(fn (AssertableInertia $page) => $page->where('message', __('accounts.popup_callback.network_taken')));
expect($this->workspace->socialAccounts()->whereIn('platform', [Platform::Instagram->value, Platform::InstagramFacebook->value])->count())->toBe(1);
});

View file

@ -134,8 +134,8 @@ function captureLinkedInConnectScopes(object $test): array
$response = $this->actingAs($this->user)->get(route('app.social.linkedin.connect'));
$response->assertOk();
$response->assertViewIs('auth.social-callback');
$response->assertViewHas('success', false);
$response->assertInertia(fn (Assert $page) => $page->component('accounts/PopupCallback'));
$response->assertInertia(fn (Assert $page) => $page->where('success', false));
});
test('linkedin callback stores the person and organizations then redirects to the selector', function () {
@ -185,8 +185,8 @@ function captureLinkedInConnectScopes(object $test): array
$response = $this->actingAs($this->user)->get(route('app.social.linkedin.callback'));
$response->assertOk();
$response->assertViewHas('success', false);
$response->assertViewHas('message', 'Session expired. Please try again.');
$response->assertInertia(fn (Assert $page) => $page->where('success', false));
$response->assertInertia(fn (Assert $page) => $page->where('message', 'Session expired. Please try again.'));
});
test('linkedin callback handles oauth errors gracefully', function () {
@ -200,8 +200,8 @@ function captureLinkedInConnectScopes(object $test): array
$response = $this->actingAs($this->user)->get(route('app.social.linkedin.callback'));
$response->assertOk();
$response->assertViewHas('success', false);
$response->assertViewHas('message', 'Error connecting account. Please try again.');
$response->assertInertia(fn (Assert $page) => $page->where('success', false));
$response->assertInertia(fn (Assert $page) => $page->where('message', 'Error connecting account. Please try again.'));
});
test('select-identity screen renders the person and organizations', function () {
@ -271,7 +271,7 @@ function captureLinkedInConnectScopes(object $test): array
'type' => 'person',
]);
$response->assertViewHas('success', false);
$response->assertInertia(fn (Assert $page) => $page->where('success', false));
$this->assertDatabaseMissing('social_accounts', ['platform_user_id' => 'person-123']);
});
@ -294,7 +294,7 @@ function captureLinkedInConnectScopes(object $test): array
'organization_id' => 123456,
]);
$response->assertViewHas('success', false);
$response->assertInertia(fn (Assert $page) => $page->where('success', false));
$this->assertDatabaseMissing('social_accounts', ['platform_user_id' => 123456]);
});
@ -304,8 +304,8 @@ function captureLinkedInConnectScopes(object $test): array
$response = $this->actingAs($this->user)->get(route('app.social.linkedin.select-identity'));
$response->assertOk();
$response->assertViewIs('auth.social-callback');
$response->assertViewHas('success', false);
$response->assertInertia(fn (Assert $page) => $page->component('accounts/PopupCallback'));
$response->assertInertia(fn (Assert $page) => $page->where('success', false));
});
test('selecting the person creates a linkedin account', function () {
@ -324,8 +324,8 @@ function captureLinkedInConnectScopes(object $test): array
]);
$response->assertOk();
$response->assertViewIs('auth.social-callback');
$response->assertViewHas('success', true);
$response->assertInertia(fn (Assert $page) => $page->component('accounts/PopupCallback'));
$response->assertInertia(fn (Assert $page) => $page->where('success', true));
$this->assertDatabaseHas('social_accounts', [
'workspace_id' => $this->workspace->id,
@ -384,7 +384,7 @@ function captureLinkedInConnectScopes(object $test): array
]);
$response->assertOk();
$response->assertViewHas('success', true);
$response->assertInertia(fn (Assert $page) => $page->where('success', true));
$account = SocialAccount::where('platform', Platform::LinkedInPage->value)
->where('platform_user_id', 123456)
@ -416,7 +416,7 @@ function captureLinkedInConnectScopes(object $test): array
'organization_id' => 999,
]);
$response->assertViewHas('success', false);
$response->assertInertia(fn (Assert $page) => $page->where('success', false));
$this->assertDatabaseMissing('social_accounts', ['platform_user_id' => 999]);
});
@ -462,8 +462,8 @@ function captureLinkedInConnectScopes(object $test): array
// A redirect-back would strand the popup; it must answer with the self-closing callback view.
$response->assertOk();
$response->assertViewIs('auth.social-callback');
$response->assertViewHas('success', false);
$response->assertInertia(fn (Assert $page) => $page->component('accounts/PopupCallback'));
$response->assertInertia(fn (Assert $page) => $page->where('success', false));
$this->assertDatabaseMissing('social_accounts', ['platform_user_id' => 'person-123']);
});
@ -473,8 +473,8 @@ function captureLinkedInConnectScopes(object $test): array
]);
$response->assertOk();
$response->assertViewHas('success', false);
$response->assertViewHas('message', 'Session expired. Please try again.');
$response->assertInertia(fn (Assert $page) => $page->where('success', false));
$response->assertInertia(fn (Assert $page) => $page->where('message', 'Session expired. Please try again.'));
});
test('selecting the person shows network_taken when a linkedin page already occupies the network', function () {
@ -500,8 +500,8 @@ function captureLinkedInConnectScopes(object $test): array
]);
$response->assertOk();
$response->assertViewHas('success', false);
$response->assertViewHas('message', __('accounts.popup_callback.network_taken'));
$response->assertInertia(fn (Assert $page) => $page->where('success', false));
$response->assertInertia(fn (Assert $page) => $page->where('message', __('accounts.popup_callback.network_taken')));
$this->assertDatabaseMissing('social_accounts', [
'platform' => Platform::LinkedIn->value,
@ -535,7 +535,7 @@ function captureLinkedInConnectScopes(object $test): array
]);
$response->assertOk();
$response->assertViewHas('success', true);
$response->assertInertia(fn (Assert $page) => $page->where('success', true));
expect($this->workspace->socialAccounts()->where('platform', Platform::LinkedInPage)->count())->toBe(2);
});

View file

@ -9,6 +9,7 @@
use App\Models\User;
use App\Models\Workspace;
use Illuminate\Support\Facades\Http;
use Inertia\Testing\AssertableInertia;
beforeEach(function () {
$this->user = User::factory()->create();
@ -92,8 +93,8 @@
]));
$response->assertOk();
$response->assertViewIs('auth.social-callback');
$response->assertViewHas('success', true);
$response->assertInertia(fn (AssertableInertia $page) => $page->component('accounts/PopupCallback'));
$response->assertInertia(fn (AssertableInertia $page) => $page->where('success', true));
$this->assertDatabaseHas('social_accounts', [
'workspace_id' => $this->workspace->id,
@ -122,7 +123,7 @@
]));
$response->assertOk();
$response->assertViewHas('success', false);
$response->assertInertia(fn (AssertableInertia $page) => $page->where('success', false));
$this->assertDatabaseMissing('social_accounts', [
'workspace_id' => $this->workspace->id,
@ -139,8 +140,8 @@
]));
$response->assertOk();
$response->assertViewHas('success', false);
$response->assertViewHas('message', 'Session expired. Please try again.');
$response->assertInertia(fn (AssertableInertia $page) => $page->where('success', false));
$response->assertInertia(fn (AssertableInertia $page) => $page->where('message', 'Session expired. Please try again.'));
});
test('user can connect multiple mastodon accounts in self-hosted mode', function () {
@ -178,7 +179,7 @@
]));
$response->assertOk();
$response->assertViewHas('success', true);
$response->assertInertia(fn (AssertableInertia $page) => $page->where('success', true));
expect($this->workspace->socialAccounts()->where('platform', Platform::Mastodon)->count())->toBe(2);
});

View file

@ -8,6 +8,7 @@
use App\Models\SocialAccount;
use App\Models\User;
use App\Models\Workspace;
use Inertia\Testing\AssertableInertia;
use Laravel\Socialite\Facades\Socialite;
use Laravel\Socialite\Two\User as SocialiteUser;
@ -62,8 +63,8 @@
$response = $this->actingAs($this->user)->get(route('app.social.pinterest.callback'));
$response->assertOk();
$response->assertViewIs('auth.social-callback');
$response->assertViewHas('success', true);
$response->assertInertia(fn (AssertableInertia $page) => $page->component('accounts/PopupCallback'));
$response->assertInertia(fn (AssertableInertia $page) => $page->where('success', true));
$this->assertDatabaseHas('social_accounts', [
'workspace_id' => $this->workspace->id,
@ -108,8 +109,8 @@
$response = $this->actingAs($this->user)->get(route('app.social.pinterest.callback'));
$response->assertOk();
$response->assertViewHas('success', false);
$response->assertViewHas('message', 'Session expired. Please try again.');
$response->assertInertia(fn (AssertableInertia $page) => $page->where('success', false));
$response->assertInertia(fn (AssertableInertia $page) => $page->where('message', 'Session expired. Please try again.'));
});
test('user can connect multiple pinterest accounts in self-hosted mode', function () {
@ -143,7 +144,7 @@
$response = $this->actingAs($this->user)->get(route('app.social.pinterest.callback'));
$response->assertOk();
$response->assertViewHas('success', true);
$response->assertInertia(fn (AssertableInertia $page) => $page->where('success', true));
expect($this->workspace->socialAccounts()->where('platform', Platform::Pinterest)->count())->toBe(2);
});
@ -163,6 +164,6 @@
$response = $this->actingAs($this->user)->get(route('app.social.pinterest.callback'));
$response->assertOk();
$response->assertViewHas('success', false);
$response->assertViewHas('message', 'Error connecting account. Please try again.');
$response->assertInertia(fn (AssertableInertia $page) => $page->where('success', false));
$response->assertInertia(fn (AssertableInertia $page) => $page->where('message', 'Error connecting account. Please try again.'));
});

View file

@ -9,6 +9,7 @@
use App\Models\User;
use App\Models\Workspace;
use Illuminate\Support\Facades\Http;
use Inertia\Testing\AssertableInertia;
beforeEach(function () {
$this->user = User::factory()->create();
@ -59,8 +60,8 @@
]));
$response->assertOk();
$response->assertViewIs('auth.social-callback');
$response->assertViewHas('success', true);
$response->assertInertia(fn (AssertableInertia $page) => $page->component('accounts/PopupCallback'));
$response->assertInertia(fn (AssertableInertia $page) => $page->where('success', true));
$this->assertDatabaseHas('social_accounts', [
'workspace_id' => $this->workspace->id,
@ -83,8 +84,8 @@
]));
$response->assertOk();
$response->assertViewHas('success', false);
$response->assertViewHas('message', 'Invalid state. Please try again.');
$response->assertInertia(fn (AssertableInertia $page) => $page->where('success', false));
$response->assertInertia(fn (AssertableInertia $page) => $page->where('message', 'Invalid state. Please try again.'));
$this->assertDatabaseMissing('social_accounts', [
'workspace_id' => $this->workspace->id,
@ -101,8 +102,8 @@
]));
$response->assertOk();
$response->assertViewHas('success', false);
$response->assertViewHas('message', 'Session expired. Please try again.');
$response->assertInertia(fn (AssertableInertia $page) => $page->where('success', false));
$response->assertInertia(fn (AssertableInertia $page) => $page->where('message', 'Session expired. Please try again.'));
});
test('user can connect multiple threads accounts in self-hosted mode', function () {
@ -142,7 +143,7 @@
]));
$response->assertOk();
$response->assertViewHas('success', true);
$response->assertInertia(fn (AssertableInertia $page) => $page->where('success', true));
expect($this->workspace->socialAccounts()->where('platform', Platform::Threads)->count())->toBe(2);
});
@ -168,8 +169,8 @@
]));
$response->assertOk();
$response->assertViewHas('success', false);
$response->assertViewHas('message', 'Error connecting account. Please try again.');
$response->assertInertia(fn (AssertableInertia $page) => $page->where('success', false));
$response->assertInertia(fn (AssertableInertia $page) => $page->where('message', 'Error connecting account. Please try again.'));
});
test('threads callback shows network_taken when the network is already connected', function () {
@ -210,9 +211,9 @@
]));
$response->assertOk();
$response->assertViewIs('auth.social-callback');
$response->assertViewHas('success', false);
$response->assertViewHas('message', __('accounts.popup_callback.network_taken'));
$response->assertInertia(fn (AssertableInertia $page) => $page->component('accounts/PopupCallback'));
$response->assertInertia(fn (AssertableInertia $page) => $page->where('success', false));
$response->assertInertia(fn (AssertableInertia $page) => $page->where('message', __('accounts.popup_callback.network_taken')));
expect($this->workspace->socialAccounts()->where('platform', Platform::Threads)->count())->toBe(1);
});

View file

@ -8,6 +8,7 @@
use App\Models\SocialAccount;
use App\Models\User;
use App\Models\Workspace;
use Inertia\Testing\AssertableInertia;
use Laravel\Socialite\Facades\Socialite;
use Laravel\Socialite\Two\User as SocialiteUser;
@ -64,8 +65,8 @@
$response = $this->actingAs($this->user)->get(route('app.social.tiktok.callback'));
$response->assertOk();
$response->assertViewIs('auth.social-callback');
$response->assertViewHas('success', true);
$response->assertInertia(fn (AssertableInertia $page) => $page->component('accounts/PopupCallback'));
$response->assertInertia(fn (AssertableInertia $page) => $page->where('success', true));
$this->assertDatabaseHas('social_accounts', [
'workspace_id' => $this->workspace->id,
@ -82,8 +83,8 @@
$response = $this->actingAs($this->user)->get(route('app.social.tiktok.callback'));
$response->assertOk();
$response->assertViewHas('success', false);
$response->assertViewHas('message', 'Session expired. Please try again.');
$response->assertInertia(fn (AssertableInertia $page) => $page->where('success', false));
$response->assertInertia(fn (AssertableInertia $page) => $page->where('message', 'Session expired. Please try again.'));
});
test('user can connect multiple tiktok accounts in self-hosted mode', function () {
@ -119,7 +120,7 @@
$response = $this->actingAs($this->user)->get(route('app.social.tiktok.callback'));
$response->assertOk();
$response->assertViewHas('success', true);
$response->assertInertia(fn (AssertableInertia $page) => $page->where('success', true));
expect($this->workspace->socialAccounts()->where('platform', Platform::TikTok)->count())->toBe(2);
});
@ -140,6 +141,6 @@
$response = $this->actingAs($this->user)->get(route('app.social.tiktok.callback'));
$response->assertOk();
$response->assertViewHas('success', false);
$response->assertViewHas('message', 'Error connecting account. Please try again.');
$response->assertInertia(fn (AssertableInertia $page) => $page->where('success', false));
$response->assertInertia(fn (AssertableInertia $page) => $page->where('message', 'Error connecting account. Please try again.'));
});

View file

@ -8,6 +8,7 @@
use App\Models\SocialAccount;
use App\Models\User;
use App\Models\Workspace;
use Inertia\Testing\AssertableInertia;
use Laravel\Socialite\Facades\Socialite;
use Laravel\Socialite\Two\User as SocialiteUser;
@ -62,8 +63,8 @@
$response = $this->actingAs($this->user)->get(route('app.social.x.callback'));
$response->assertOk();
$response->assertViewIs('auth.social-callback');
$response->assertViewHas('success', true);
$response->assertInertia(fn (AssertableInertia $page) => $page->component('accounts/PopupCallback'));
$response->assertInertia(fn (AssertableInertia $page) => $page->where('success', true));
$this->assertDatabaseHas('social_accounts', [
'workspace_id' => $this->workspace->id,
@ -80,8 +81,8 @@
$response = $this->actingAs($this->user)->get(route('app.social.x.callback'));
$response->assertOk();
$response->assertViewHas('success', false);
$response->assertViewHas('message', 'Session expired. Please try again.');
$response->assertInertia(fn (AssertableInertia $page) => $page->where('success', false));
$response->assertInertia(fn (AssertableInertia $page) => $page->where('message', 'Session expired. Please try again.'));
});
test('user can connect multiple x accounts in self-hosted mode', function () {
@ -115,7 +116,7 @@
$response = $this->actingAs($this->user)->get(route('app.social.x.callback'));
$response->assertOk();
$response->assertViewHas('success', true);
$response->assertInertia(fn (AssertableInertia $page) => $page->where('success', true));
expect($this->workspace->socialAccounts()->where('platform', Platform::X)->count())->toBe(2);
});
@ -135,6 +136,6 @@
$response = $this->actingAs($this->user)->get(route('app.social.x.callback'));
$response->assertOk();
$response->assertViewHas('success', false);
$response->assertViewHas('message', 'Error connecting account. Please try again.');
$response->assertInertia(fn (AssertableInertia $page) => $page->where('success', false));
$response->assertInertia(fn (AssertableInertia $page) => $page->where('message', 'Error connecting account. Please try again.'));
});

View file

@ -9,6 +9,7 @@
use App\Models\User;
use App\Models\Workspace;
use Illuminate\Support\Facades\Http;
use Inertia\Testing\AssertableInertia;
use Laravel\Socialite\Facades\Socialite;
use Laravel\Socialite\Two\User as SocialiteUser;
@ -81,8 +82,8 @@
$response = $this->actingAs($this->user)->get(route('app.social.youtube.callback'));
$response->assertOk();
$response->assertViewIs('auth.social-callback');
$response->assertViewHas('success', true);
$response->assertInertia(fn (AssertableInertia $page) => $page->component('accounts/PopupCallback'));
$response->assertInertia(fn (AssertableInertia $page) => $page->where('success', true));
$this->assertDatabaseHas('social_accounts', [
'workspace_id' => $this->workspace->id,
@ -168,8 +169,8 @@
$response = $this->actingAs($this->user)->get(route('app.social.youtube.callback'));
$response->assertOk();
$response->assertViewHas('success', false);
$response->assertViewHas('message', 'No YouTube channels found. Please create a channel first.');
$response->assertInertia(fn (AssertableInertia $page) => $page->where('success', false));
$response->assertInertia(fn (AssertableInertia $page) => $page->where('message', 'No YouTube channels found. Please create a channel first.'));
});
test('youtube callback fails with expired session', function () {
@ -178,8 +179,8 @@
$response = $this->actingAs($this->user)->get(route('app.social.youtube.callback'));
$response->assertOk();
$response->assertViewHas('success', false);
$response->assertViewHas('message', 'Session expired. Please try again.');
$response->assertInertia(fn (AssertableInertia $page) => $page->where('success', false));
$response->assertInertia(fn (AssertableInertia $page) => $page->where('message', 'Session expired. Please try again.'));
});
test('user can connect multiple youtube accounts in self-hosted mode', function () {
@ -225,7 +226,7 @@
$response = $this->actingAs($this->user)->get(route('app.social.youtube.callback'));
$response->assertOk();
$response->assertViewHas('success', true);
$response->assertInertia(fn (AssertableInertia $page) => $page->where('success', true));
expect($this->workspace->socialAccounts()->where('platform', Platform::YouTube)->count())->toBe(2);
});
@ -245,8 +246,8 @@
$response = $this->actingAs($this->user)->get(route('app.social.youtube.callback'));
$response->assertOk();
$response->assertViewHas('success', false);
$response->assertViewHas('message', 'Error connecting account. Please try again.');
$response->assertInertia(fn (AssertableInertia $page) => $page->where('success', false));
$response->assertInertia(fn (AssertableInertia $page) => $page->where('message', 'Error connecting account. Please try again.'));
});
test('youtube channel selection creates account', function () {
@ -282,7 +283,7 @@
]);
$response->assertOk();
$response->assertViewHas('success', true);
$response->assertInertia(fn (AssertableInertia $page) => $page->where('success', true));
$this->assertDatabaseHas('social_accounts', [
'workspace_id' => $this->workspace->id,
@ -300,8 +301,8 @@
]);
$response->assertOk();
$response->assertViewHas('success', false);
$response->assertViewHas('message', 'Session expired. Please try again.');
$response->assertInertia(fn (AssertableInertia $page) => $page->where('success', false));
$response->assertInertia(fn (AssertableInertia $page) => $page->where('message', 'Session expired. Please try again.'));
});
test('youtube callback shows network_taken when the network is already connected', function () {
@ -347,9 +348,9 @@
$response = $this->actingAs($this->user)->get(route('app.social.youtube.callback'));
$response->assertOk();
$response->assertViewIs('auth.social-callback');
$response->assertViewHas('success', false);
$response->assertViewHas('message', __('accounts.popup_callback.network_taken'));
$response->assertInertia(fn (AssertableInertia $page) => $page->component('accounts/PopupCallback'));
$response->assertInertia(fn (AssertableInertia $page) => $page->where('success', false));
$response->assertInertia(fn (AssertableInertia $page) => $page->where('message', __('accounts.popup_callback.network_taken')));
expect($this->workspace->socialAccounts()->where('platform', Platform::YouTube)->count())->toBe(1);
});