Polish PR: status FormRequest, sanitizer anchor guard, webhook limit, dedupe OAuth popup
This commit is contained in:
parent
2585d89cb4
commit
c096092c7b
9 changed files with 348 additions and 127 deletions
|
|
@ -6,6 +6,7 @@
|
|||
|
||||
use App\Enums\SocialAccount\Platform as SocialPlatform;
|
||||
use App\Enums\SocialAccount\TelegramConnectStatus;
|
||||
use App\Http\Requests\App\Auth\TelegramStatusRequest;
|
||||
use App\Models\TelegramConnectRequest;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
|
@ -47,14 +48,14 @@ public function connect(Request $request): JsonResponse
|
|||
/**
|
||||
* Poll whether the channel has been linked yet.
|
||||
*/
|
||||
public function status(Request $request): JsonResponse
|
||||
public function status(TelegramStatusRequest $request): JsonResponse
|
||||
{
|
||||
$workspace = $request->user()->currentWorkspace;
|
||||
abort_if($workspace === null, SymfonyResponse::HTTP_CONFLICT, 'No active workspace.');
|
||||
|
||||
$connectRequest = TelegramConnectRequest::query()
|
||||
->where('workspace_id', $workspace->id)
|
||||
->where('code', (string) $request->query('code'))
|
||||
->where('code', $request->validated('code'))
|
||||
->first();
|
||||
|
||||
return response()->json([
|
||||
|
|
|
|||
|
|
@ -6,10 +6,13 @@
|
|||
|
||||
use App\Enums\SocialAccount\Platform as SocialPlatform;
|
||||
use App\Enums\SocialAccount\Status;
|
||||
use App\Features\SocialAccountLimit;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\TelegramConnectRequest;
|
||||
use App\Models\Workspace;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Laravel\Pennant\Feature;
|
||||
use Symfony\Component\HttpFoundation\Response as SymfonyResponse;
|
||||
|
||||
class TelegramWebhookController extends Controller
|
||||
|
|
@ -49,7 +52,19 @@ public function handle(Request $request): Response
|
|||
$chatId = (string) data_get($chat, 'id');
|
||||
$username = data_get($chat, 'username');
|
||||
|
||||
$account = $connectRequest->workspace->socialAccounts()->updateOrCreate(
|
||||
$workspace = $connectRequest->workspace;
|
||||
|
||||
// Mirror the controller's limit gate: block only brand-new accounts, never reconnects.
|
||||
$isNewAccount = ! $workspace->socialAccounts()
|
||||
->where('platform', SocialPlatform::Telegram->value)
|
||||
->where('platform_user_id', $chatId)
|
||||
->exists();
|
||||
|
||||
if ($isNewAccount && $this->workspaceAtAccountLimit($workspace)) {
|
||||
return response()->noContent();
|
||||
}
|
||||
|
||||
$account = $workspace->socialAccounts()->updateOrCreate(
|
||||
[
|
||||
'platform' => SocialPlatform::Telegram->value,
|
||||
'platform_user_id' => $chatId,
|
||||
|
|
@ -76,4 +91,15 @@ public function handle(Request $request): Response
|
|||
|
||||
return response()->noContent();
|
||||
}
|
||||
|
||||
private function workspaceAtAccountLimit(Workspace $workspace): bool
|
||||
{
|
||||
if (config('trypost.self_hosted')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$limit = Feature::for($workspace->account)->value(SocialAccountLimit::class);
|
||||
|
||||
return $workspace->socialAccounts()->count() >= $limit;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
25
app/Http/Requests/App/Auth/TelegramStatusRequest.php
Normal file
25
app/Http/Requests/App/Auth/TelegramStatusRequest.php
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\App\Auth;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class TelegramStatusRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'code' => ['required', 'string'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -35,6 +35,9 @@ private function toTelegramHtml(string $content): string
|
|||
$content = preg_replace(['/<(\/?)strong>/i', '/<(\/?)em>/i'], ['<$1b>', '<$1i>'], $content);
|
||||
$content = strip_tags($content, ['b', 'i', 'u', 's', 'a', 'code', 'pre']);
|
||||
|
||||
// Telegram requires every <a> to carry an href; drop bare anchors so the parser doesn't reject the whole message.
|
||||
$content = preg_replace('/<a(?![^>]*\shref=)[^>]*>(.*?)<\/a>/is', '$1', $content);
|
||||
|
||||
// Escape bare ampersands while leaving existing entities intact.
|
||||
$content = preg_replace('/&(?!(?:amp|lt|gt|quot|#\d+);)/', '&', $content);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,13 +1,25 @@
|
|||
<script setup lang="ts">
|
||||
import { router } from '@inertiajs/vue3';
|
||||
import { IconAlertCircle, IconCheck, IconExternalLink, IconRefresh, IconTrash } from '@tabler/icons-vue';
|
||||
import {
|
||||
IconAlertCircle,
|
||||
IconCheck,
|
||||
IconExternalLink,
|
||||
IconRefresh,
|
||||
IconTrash,
|
||||
} from '@tabler/icons-vue';
|
||||
import { trans } from 'laravel-vue-i18n';
|
||||
import { computed, onMounted, onUnmounted } from 'vue';
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import { useOAuthPopup } from '@/composables/useOAuthPopup';
|
||||
import { getPlatformLogo } from '@/composables/usePlatformLogo';
|
||||
import { toggle as toggleAccount } from '@/routes/app/accounts';
|
||||
|
||||
|
|
@ -47,44 +59,16 @@ const props = withDefaults(defineProps<Props>(), {
|
|||
});
|
||||
|
||||
const handleToggle = (accountId: string) => {
|
||||
router.put(toggleAccount.url(accountId), {}, {
|
||||
preserveScroll: true,
|
||||
});
|
||||
};
|
||||
|
||||
const getConnectUrl = (platformValue: string): string => {
|
||||
return `/connect/${platformValue}`;
|
||||
};
|
||||
|
||||
const openOAuthPopup = (platformValue: string) => {
|
||||
const url = getConnectUrl(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`
|
||||
router.put(
|
||||
toggleAccount.url(accountId),
|
||||
{},
|
||||
{
|
||||
preserveScroll: true,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const handleOAuthMessage = (event: MessageEvent) => {
|
||||
if (event.origin !== window.location.origin) return;
|
||||
if (event.data?.type !== 'social-oauth-callback') return;
|
||||
|
||||
// Reload the page to get fresh data
|
||||
router.reload();
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('message', handleOAuthMessage);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('message', handleOAuthMessage);
|
||||
});
|
||||
const { openOAuthPopup } = useOAuthPopup(() => router.reload());
|
||||
|
||||
const gridClass = computed(() => {
|
||||
switch (props.columns) {
|
||||
|
|
@ -102,7 +86,11 @@ const emit = defineEmits<{
|
|||
disconnect: [accountId: string];
|
||||
}>();
|
||||
|
||||
const getProfileUrl = (platform: string, username: string | null, platformUserId: string | null = null): string | null => {
|
||||
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;
|
||||
|
|
@ -111,66 +99,112 @@ const getProfileUrl = (platform: string, username: string | null, platformUserId
|
|||
if (!username) return null;
|
||||
|
||||
const urls: Record<string, string> = {
|
||||
'linkedin': `https://linkedin.com/in/${username}`,
|
||||
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}`,
|
||||
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}`,
|
||||
'telegram': `https://t.me/${username}`,
|
||||
youtube: `https://youtube.com/@${username}`,
|
||||
threads: `https://threads.net/@${username}`,
|
||||
bluesky: `https://bsky.app/profile/${username}`,
|
||||
pinterest: `https://pinterest.com/${username}`,
|
||||
telegram: `https://t.me/${username}`,
|
||||
};
|
||||
return urls[platform] || null;
|
||||
};
|
||||
|
||||
const isDisconnected = (account: SocialAccount | null): boolean => {
|
||||
if (!account) return false;
|
||||
return account.status === 'disconnected' || account.status === 'token_expired';
|
||||
return (
|
||||
account.status === 'disconnected' || account.status === 'token_expired'
|
||||
);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="grid gap-4" :class="gridClass">
|
||||
<div v-for="platform in platforms" :key="platform.value"
|
||||
class="group relative overflow-hidden rounded-xl border bg-card transition-all hover:shadow-md" :class="{
|
||||
'border-green-500/30 bg-green-50/50 dark:bg-green-950/20': platform.connected && !isDisconnected(platform.account) && platform.account?.is_active,
|
||||
'border-red-500/30 bg-red-50/50 dark:bg-red-950/20': platform.connected && (isDisconnected(platform.account) || !platform.account?.is_active),
|
||||
}">
|
||||
<div
|
||||
v-for="platform in platforms"
|
||||
:key="platform.value"
|
||||
class="group relative overflow-hidden rounded-xl border bg-card transition-all hover:shadow-md"
|
||||
:class="{
|
||||
'border-green-500/30 bg-green-50/50 dark:bg-green-950/20':
|
||||
platform.connected &&
|
||||
!isDisconnected(platform.account) &&
|
||||
platform.account?.is_active,
|
||||
'border-red-500/30 bg-red-50/50 dark:bg-red-950/20':
|
||||
platform.connected &&
|
||||
(isDisconnected(platform.account) ||
|
||||
!platform.account?.is_active),
|
||||
}"
|
||||
>
|
||||
<!-- Platform Header -->
|
||||
<div class="flex items-center gap-3 p-4">
|
||||
<div class="relative">
|
||||
<img :src="getPlatformLogo(platform.value)" :alt="platform.label"
|
||||
<img
|
||||
:src="getPlatformLogo(platform.value)"
|
||||
:alt="platform.label"
|
||||
class="h-10 w-10 rounded-full object-contain"
|
||||
:class="{ 'opacity-40': platform.connected && platform.account && !platform.account.is_active }" />
|
||||
<div v-if="platform.connected && !isDisconnected(platform.account)"
|
||||
class="absolute -bottom-0.5 -right-0.5 flex h-4 w-4 items-center justify-center rounded-full bg-green-500 text-white ring-2 ring-white dark:ring-neutral-900">
|
||||
:class="{
|
||||
'opacity-40':
|
||||
platform.connected &&
|
||||
platform.account &&
|
||||
!platform.account.is_active,
|
||||
}"
|
||||
/>
|
||||
<div
|
||||
v-if="
|
||||
platform.connected &&
|
||||
!isDisconnected(platform.account)
|
||||
"
|
||||
class="absolute -right-0.5 -bottom-0.5 flex h-4 w-4 items-center justify-center rounded-full bg-green-500 text-white ring-2 ring-white dark:ring-neutral-900"
|
||||
>
|
||||
<IconCheck class="h-2 w-2" />
|
||||
</div>
|
||||
<div v-else-if="platform.connected && isDisconnected(platform.account)"
|
||||
class="absolute -bottom-0.5 -right-0.5 flex h-4 w-4 items-center justify-center rounded-full bg-red-500 text-white ring-2 ring-white dark:ring-neutral-900">
|
||||
<div
|
||||
v-else-if="
|
||||
platform.connected &&
|
||||
isDisconnected(platform.account)
|
||||
"
|
||||
class="absolute -right-0.5 -bottom-0.5 flex h-4 w-4 items-center justify-center rounded-full bg-red-500 text-white ring-2 ring-white dark:ring-neutral-900"
|
||||
>
|
||||
<IconAlertCircle class="h-2 w-2" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<div class="flex items-center gap-1 min-w-0">
|
||||
<h3 class="font-semibold leading-tight">
|
||||
<div class="flex min-w-0 items-center gap-1">
|
||||
<h3 class="leading-tight font-semibold">
|
||||
<template v-if="platform.label.includes('(')">
|
||||
{{ platform.label.split('(')[0].trim() }}<br />
|
||||
<span class="text-xs font-normal text-muted-foreground">({{
|
||||
platform.label.split('(')[1] }}</span>
|
||||
{{ platform.label.split('(')[0].trim()
|
||||
}}<br />
|
||||
<span
|
||||
class="text-xs font-normal text-muted-foreground"
|
||||
>({{
|
||||
platform.label.split('(')[1]
|
||||
}}</span
|
||||
>
|
||||
</template>
|
||||
<template v-else>{{ platform.label }}</template>
|
||||
</h3>
|
||||
</div>
|
||||
<Switch v-if="platform.connected && platform.account" :model-value="platform.account.is_active"
|
||||
@update:model-value="handleToggle(platform.account.id)" />
|
||||
<Switch
|
||||
v-if="platform.connected && platform.account"
|
||||
:model-value="platform.account.is_active"
|
||||
@update:model-value="
|
||||
handleToggle(platform.account.id)
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<p v-if="platform.connected && platform.account" class="text-sm text-muted-foreground truncate">
|
||||
@{{ platform.account.username || platform.account.display_name }}
|
||||
<p
|
||||
v-if="platform.connected && platform.account"
|
||||
class="truncate text-sm text-muted-foreground"
|
||||
>
|
||||
@{{
|
||||
platform.account.username ||
|
||||
platform.account.display_name
|
||||
}}
|
||||
</p>
|
||||
<p v-else class="text-sm text-muted-foreground">
|
||||
{{ trans('accounts.not_connected') }}
|
||||
|
|
@ -179,14 +213,24 @@ const isDisconnected = (account: SocialAccount | null): boolean => {
|
|||
</div>
|
||||
|
||||
<!-- Connected State -->
|
||||
<div v-if="platform.connected && platform.account" class="border-t px-4 py-3">
|
||||
<div
|
||||
v-if="platform.connected && platform.account"
|
||||
class="border-t px-4 py-3"
|
||||
>
|
||||
<!-- Disconnected Warning -->
|
||||
<div v-if="isDisconnected(platform.account)"
|
||||
class="mb-3 flex items-start gap-2 rounded-lg bg-red-100 p-2 text-sm text-red-700 dark:bg-red-900/30 dark:text-red-400">
|
||||
<IconAlertCircle class="h-4 w-4 mt-0.5 shrink-0" />
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="font-medium">{{ trans('accounts.connection_lost') }}</p>
|
||||
<p v-if="platform.account.error_message" class="text-xs truncate opacity-80">
|
||||
<div
|
||||
v-if="isDisconnected(platform.account)"
|
||||
class="mb-3 flex items-start gap-2 rounded-lg bg-red-100 p-2 text-sm text-red-700 dark:bg-red-900/30 dark:text-red-400"
|
||||
>
|
||||
<IconAlertCircle class="mt-0.5 h-4 w-4 shrink-0" />
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="font-medium">
|
||||
{{ trans('accounts.connection_lost') }}
|
||||
</p>
|
||||
<p
|
||||
v-if="platform.account.error_message"
|
||||
class="truncate text-xs opacity-80"
|
||||
>
|
||||
{{ platform.account.error_message }}
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -195,38 +239,77 @@ const isDisconnected = (account: SocialAccount | null): boolean => {
|
|||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<Avatar class="h-8 w-8">
|
||||
<AvatarImage v-if="platform.account.avatar_url" :src="platform.account.avatar_url" />
|
||||
<AvatarImage
|
||||
v-if="platform.account.avatar_url"
|
||||
:src="platform.account.avatar_url"
|
||||
/>
|
||||
<AvatarFallback class="text-xs">
|
||||
{{ platform.account.display_name?.charAt(0) }}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span class="text-sm font-medium truncate max-w-[120px]">
|
||||
<span
|
||||
class="max-w-[120px] truncate text-sm font-medium"
|
||||
>
|
||||
{{ platform.account.display_name }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<!-- Reconnect button for disconnected accounts -->
|
||||
<TooltipProvider v-if="showReconnect && isDisconnected(platform.account)">
|
||||
<TooltipProvider
|
||||
v-if="
|
||||
showReconnect &&
|
||||
isDisconnected(platform.account)
|
||||
"
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<Button variant="ghost" size="icon"
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="size-8 text-amber-600 hover:text-amber-700"
|
||||
@click="openOAuthPopup(platform.value)">
|
||||
@click="openOAuthPopup(platform.value)"
|
||||
>
|
||||
<IconRefresh class="size-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{{ trans('accounts.reconnect_account') }}</p>
|
||||
<p>
|
||||
{{
|
||||
trans('accounts.reconnect_account')
|
||||
}}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<TooltipProvider
|
||||
v-if="showViewProfile && getProfileUrl(platform.value, platform.account.username, platform.account.platform_user_id)">
|
||||
v-if="
|
||||
showViewProfile &&
|
||||
getProfileUrl(
|
||||
platform.value,
|
||||
platform.account.username,
|
||||
platform.account.platform_user_id,
|
||||
)
|
||||
"
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<Button variant="ghost" size="icon" class="size-8" as-child>
|
||||
<a :href="getProfileUrl(platform.value, platform.account.username, platform.account.platform_user_id)!"
|
||||
target="_blank">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="size-8"
|
||||
as-child
|
||||
>
|
||||
<a
|
||||
:href="
|
||||
getProfileUrl(
|
||||
platform.value,
|
||||
platform.account.username,
|
||||
platform.account
|
||||
.platform_user_id,
|
||||
)!
|
||||
"
|
||||
target="_blank"
|
||||
>
|
||||
<IconExternalLink class="size-4" />
|
||||
</a>
|
||||
</Button>
|
||||
|
|
@ -239,8 +322,17 @@ const isDisconnected = (account: SocialAccount | null): boolean => {
|
|||
<TooltipProvider v-if="showDisconnect">
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<Button variant="ghost" size="icon" class="size-8"
|
||||
@click="emit('disconnect', platform.account.id)">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="size-8"
|
||||
@click="
|
||||
emit(
|
||||
'disconnect',
|
||||
platform.account.id,
|
||||
)
|
||||
"
|
||||
>
|
||||
<IconTrash class="size-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
|
|
@ -255,7 +347,12 @@ const isDisconnected = (account: SocialAccount | null): boolean => {
|
|||
|
||||
<!-- Not Connected State -->
|
||||
<div v-else class="border-t px-4 py-3">
|
||||
<Button variant="outline" class="w-full" size="sm" @click="openOAuthPopup(platform.value)">
|
||||
<Button
|
||||
variant="outline"
|
||||
class="w-full"
|
||||
size="sm"
|
||||
@click="openOAuthPopup(platform.value)"
|
||||
>
|
||||
{{ trans('accounts.connect') }}
|
||||
</Button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
import { router } from '@inertiajs/vue3';
|
||||
import { IconPlus } from '@tabler/icons-vue';
|
||||
import { trans } from 'laravel-vue-i18n';
|
||||
import { onMounted, onUnmounted, ref } from 'vue';
|
||||
import { ref } from 'vue';
|
||||
|
||||
import TelegramConnectDialog from '@/components/accounts/TelegramConnectDialog.vue';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
|
@ -13,6 +13,7 @@ import {
|
|||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { useOAuthPopup } from '@/composables/useOAuthPopup';
|
||||
|
||||
export interface AvailablePlatform {
|
||||
value: string;
|
||||
|
|
@ -109,47 +110,21 @@ const themeFor = (value: string) =>
|
|||
|
||||
const telegramOpen = ref(false);
|
||||
|
||||
const { openOAuthPopup } = useOAuthPopup(() => {
|
||||
open.value = false;
|
||||
router.reload();
|
||||
});
|
||||
|
||||
const connectPlatform = (platformValue: string) => {
|
||||
open.value = false;
|
||||
|
||||
if (platformValue === 'telegram') {
|
||||
open.value = false;
|
||||
telegramOpen.value = true;
|
||||
return;
|
||||
}
|
||||
|
||||
openOAuthPopup(platformValue);
|
||||
};
|
||||
|
||||
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;
|
||||
|
||||
open.value = false;
|
||||
|
||||
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>
|
||||
|
|
|
|||
34
resources/js/composables/useOAuthPopup.ts
Normal file
34
resources/js/composables/useOAuthPopup.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import { onMounted, onUnmounted } from 'vue';
|
||||
|
||||
const POPUP_WIDTH = 600;
|
||||
const POPUP_HEIGHT = 700;
|
||||
|
||||
/**
|
||||
* Opens a platform's `/connect/{platform}` OAuth flow in a centered popup and
|
||||
* invokes `onSuccess` when the popup posts back the `social-oauth-callback`
|
||||
* message. The listener is wired to the calling component's lifecycle.
|
||||
*/
|
||||
export const useOAuthPopup = (onSuccess: () => void) => {
|
||||
const openOAuthPopup = (platform: string) => {
|
||||
const left = window.screenX + (window.outerWidth - POPUP_WIDTH) / 2;
|
||||
const top = window.screenY + (window.outerHeight - POPUP_HEIGHT) / 2;
|
||||
|
||||
window.open(
|
||||
`/connect/${platform}`,
|
||||
'oauth-popup',
|
||||
`width=${POPUP_WIDTH},height=${POPUP_HEIGHT},left=${left},top=${top},scrollbars=yes,resizable=yes`,
|
||||
);
|
||||
};
|
||||
|
||||
const handleMessage = (event: MessageEvent) => {
|
||||
if (event.origin !== window.location.origin) return;
|
||||
if (event.data?.type !== 'social-oauth-callback') return;
|
||||
|
||||
onSuccess();
|
||||
};
|
||||
|
||||
onMounted(() => window.addEventListener('message', handleMessage));
|
||||
onUnmounted(() => window.removeEventListener('message', handleMessage));
|
||||
|
||||
return { openOAuthPopup };
|
||||
};
|
||||
|
|
@ -94,3 +94,9 @@
|
|||
$result = $sanitizer->sanitize('Tom & Jerry & friends', Platform::Telegram);
|
||||
expect($result)->toBe('Tom & Jerry & friends');
|
||||
});
|
||||
|
||||
test('it drops anchors without an href for telegram', function () {
|
||||
$sanitizer = new ContentSanitizer;
|
||||
$result = $sanitizer->sanitize('<p>see <a>bare</a> and <a href="https://x.com">link</a></p>', Platform::Telegram);
|
||||
expect($result)->toBe('see bare and <a href="https://x.com">link</a>');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -102,6 +102,60 @@ function telegramUpdate(string $code, array $chat = []): array
|
|||
expect(data_get($account->meta, 'username'))->toBeNull();
|
||||
});
|
||||
|
||||
it('does not create a new telegram account when the workspace is at its limit', function () {
|
||||
config(['trypost.self_hosted' => false]);
|
||||
|
||||
SocialAccount::factory()->count(5)->create(['workspace_id' => $this->workspace->id]);
|
||||
|
||||
TelegramConnectRequest::create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'user_id' => $this->user->id,
|
||||
'code' => 'limitcode',
|
||||
'expires_at' => now()->addMinutes(15),
|
||||
]);
|
||||
|
||||
$this->withHeader('X-Telegram-Bot-Api-Secret-Token', 'shh-secret')
|
||||
->postJson(route('telegram.webhook'), telegramUpdate('limitcode'))
|
||||
->assertNoContent();
|
||||
|
||||
expect($this->workspace->socialAccounts()->count())->toBe(5);
|
||||
expect(
|
||||
SocialAccount::where('platform', Platform::Telegram)->where('platform_user_id', '-1001234567890')->exists()
|
||||
)->toBeFalse();
|
||||
});
|
||||
|
||||
it('still reconnects an existing telegram channel even at the account limit', function () {
|
||||
config(['trypost.self_hosted' => false]);
|
||||
|
||||
SocialAccount::factory()->count(4)->create(['workspace_id' => $this->workspace->id]);
|
||||
SocialAccount::factory()->telegram()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'platform_user_id' => '-1001234567890',
|
||||
]);
|
||||
|
||||
TelegramConnectRequest::create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'user_id' => $this->user->id,
|
||||
'code' => 'reconnectcode',
|
||||
'expires_at' => now()->addMinutes(15),
|
||||
]);
|
||||
|
||||
$this->withHeader('X-Telegram-Bot-Api-Secret-Token', 'shh-secret')
|
||||
->postJson(route('telegram.webhook'), telegramUpdate('reconnectcode'))
|
||||
->assertNoContent();
|
||||
|
||||
expect($this->workspace->socialAccounts()->count())->toBe(5);
|
||||
expect(
|
||||
SocialAccount::where('platform', Platform::Telegram)->where('platform_user_id', '-1001234567890')->count()
|
||||
)->toBe(1);
|
||||
});
|
||||
|
||||
it('requires a code to check connection status', function () {
|
||||
$this->actingAs($this->user)
|
||||
->getJson(route('app.social.telegram.status'))
|
||||
->assertStatus(422);
|
||||
});
|
||||
|
||||
it('rejects the webhook without the secret token', function () {
|
||||
$this->postJson(route('telegram.webhook'), telegramUpdate('whatever'))
|
||||
->assertForbidden();
|
||||
|
|
|
|||
Loading…
Reference in a new issue