Skip to content

Commit 0436ecb

Browse files
committed
feat: initial integrations page, update vercel account if re-added
1 parent 1071f1d commit 0436ecb

File tree

8 files changed

+597
-1
lines changed

8 files changed

+597
-1
lines changed
Lines changed: 272 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,272 @@
1+
'use client';
2+
3+
import { CheckCircleIcon, LinkIcon, PlusIcon, WarningIcon } from '@phosphor-icons/react';
4+
import Image from 'next/image';
5+
import { useSearchParams } from 'next/navigation';
6+
import { useEffect, useState } from 'react';
7+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
8+
import { Button } from '@/components/ui/button';
9+
import { Badge } from '@/components/ui/badge';
10+
import { Skeleton } from '@/components/ui/skeleton';
11+
import { useIntegrations, useDisconnectIntegration, type Integration } from '@/hooks/use-integrations';
12+
import { trpc } from '@/lib/trpc';
13+
14+
const categoryLabels = {
15+
deployment: 'Deployment',
16+
analytics: 'Analytics',
17+
monitoring: 'Monitoring',
18+
communication: 'Communication',
19+
};
20+
21+
function LoadingSkeleton() {
22+
return (
23+
<div className="space-y-8">
24+
<div className="space-y-2">
25+
<Skeleton className="h-8 w-48" />
26+
<Skeleton className="h-4 w-96" />
27+
</div>
28+
<div className="space-y-4">
29+
<div className="flex items-center gap-2">
30+
<Skeleton className="h-6 w-24" />
31+
<Skeleton className="h-5 w-8" />
32+
</div>
33+
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
34+
{[1, 2, 3].map((num) => (
35+
<Card key={num} className="animate-pulse">
36+
<CardHeader className="pb-3">
37+
<div className="flex items-center justify-between">
38+
<div className="flex items-center gap-3">
39+
<Skeleton className="h-10 w-10 rounded" />
40+
<Skeleton className="h-5 w-20" />
41+
</div>
42+
</div>
43+
</CardHeader>
44+
<CardContent className="space-y-4">
45+
<Skeleton className="h-12 w-full" />
46+
<Skeleton className="h-9 w-full" />
47+
</CardContent>
48+
</Card>
49+
))}
50+
</div>
51+
</div>
52+
</div>
53+
);
54+
}
55+
56+
function ErrorState({ onRetry }: { onRetry: () => void }) {
57+
return (
58+
<div className="flex h-64 items-center justify-center">
59+
<div className="text-center">
60+
<WarningIcon className="mx-auto h-12 w-12 text-destructive" />
61+
<h3 className="mt-2 font-medium text-foreground text-sm">
62+
Failed to load integrations
63+
</h3>
64+
<p className="mt-1 text-muted-foreground text-sm">
65+
There was an issue loading your integrations. Please try again.
66+
</p>
67+
<Button onClick={onRetry} variant="outline" className="mt-4">
68+
Try Again
69+
</Button>
70+
</div>
71+
</div>
72+
);
73+
}
74+
75+
export default function IntegrationsPage() {
76+
const searchParams = useSearchParams();
77+
const [connectingProvider, setConnectingProvider] = useState<string | null>(null);
78+
const [showSuccessMessage, setShowSuccessMessage] = useState(false);
79+
const { integrations, isLoading, isError, refetch } = useIntegrations();
80+
const disconnectMutation = useDisconnectIntegration();
81+
82+
// Check for success message from OAuth callback
83+
useEffect(() => {
84+
if (searchParams.get('vercel_integrated') === 'true') {
85+
setShowSuccessMessage(true);
86+
refetch(); // Refresh integrations to show the new connection
87+
88+
// Remove the query parameter from URL
89+
const url = new URL(window.location.href);
90+
url.searchParams.delete('vercel_integrated');
91+
window.history.replaceState({}, '', url.toString());
92+
93+
// Hide success message after 5 seconds
94+
const timer = setTimeout(() => {
95+
setShowSuccessMessage(false);
96+
}, 5000);
97+
98+
return () => clearTimeout(timer);
99+
}
100+
}, [searchParams, refetch]);
101+
102+
const handleConnect = (integration: Integration) => {
103+
if (integration.id === 'vercel') {
104+
setConnectingProvider(integration.id);
105+
106+
// Redirect directly to Vercel OAuth
107+
const clientId = process.env.NEXT_PUBLIC_VERCEL_CLIENT_ID;
108+
const redirectUri = `${window.location.origin}/api/integrations/vercel/callback`;
109+
const state = encodeURIComponent(window.location.href);
110+
111+
const vercelAuthUrl = new URL('https://vercel.com/oauth/authorize');
112+
vercelAuthUrl.searchParams.set('client_id', clientId || '');
113+
vercelAuthUrl.searchParams.set('redirect_uri', redirectUri);
114+
vercelAuthUrl.searchParams.set('response_type', 'code');
115+
vercelAuthUrl.searchParams.set('scope', 'user:read');
116+
vercelAuthUrl.searchParams.set('state', state);
117+
118+
window.location.href = vercelAuthUrl.toString();
119+
}
120+
};
121+
122+
const handleDisconnect = async (integration: Integration) => {
123+
try {
124+
await disconnectMutation.mutateAsync({
125+
provider: integration.id as 'vercel',
126+
});
127+
} catch (error) {
128+
console.error('Failed to disconnect integration:', error);
129+
}
130+
};
131+
132+
if (isLoading) {
133+
return <LoadingSkeleton />;
134+
}
135+
136+
if (isError) {
137+
return <ErrorState onRetry={refetch} />;
138+
}
139+
140+
const groupedIntegrations = integrations.reduce((acc, integration) => {
141+
if (!acc[integration.category]) {
142+
acc[integration.category] = [];
143+
}
144+
acc[integration.category].push(integration);
145+
return acc;
146+
}, {} as Record<string, Integration[]>);
147+
148+
return (
149+
<div className="space-y-8">
150+
{showSuccessMessage && (
151+
<div className="rounded-lg border border-green-200 bg-green-50 p-4 dark:border-green-800 dark:bg-green-950">
152+
<div className="flex items-center gap-3">
153+
<CheckCircleIcon className="h-5 w-5 text-green-600 dark:text-green-400" />
154+
<div>
155+
<h3 className="font-medium text-green-800 dark:text-green-200">
156+
Integration Connected Successfully
157+
</h3>
158+
<p className="text-green-700 text-sm dark:text-green-300">
159+
Vercel has been connected to your account. You can now manage your deployments.
160+
</p>
161+
</div>
162+
<Button
163+
variant="ghost"
164+
size="sm"
165+
onClick={() => setShowSuccessMessage(false)}
166+
className="ml-auto text-green-600 hover:text-green-800 dark:text-green-400 dark:hover:text-green-200"
167+
>
168+
×
169+
</Button>
170+
</div>
171+
</div>
172+
)}
173+
174+
<div className="space-y-2">
175+
<h2 className="font-semibold text-2xl tracking-tight">Integrations</h2>
176+
<p className="text-muted-foreground">
177+
Connect your favorite tools and services to enhance your workflow.
178+
</p>
179+
</div>
180+
181+
{Object.entries(groupedIntegrations).map(([category, categoryIntegrations]) => (
182+
<div key={category} className="space-y-4">
183+
<div className="flex items-center gap-2">
184+
<h3 className="font-medium text-lg">{categoryLabels[category as keyof typeof categoryLabels]}</h3>
185+
<Badge variant="secondary" className="text-xs">
186+
{categoryIntegrations.length}
187+
</Badge>
188+
</div>
189+
190+
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
191+
{categoryIntegrations.map((integration) => (
192+
<Card key={integration.id} className="relative shadow-sm transition-shadow hover:shadow-md">
193+
<CardHeader className="pb-3">
194+
<div className="flex items-center justify-between">
195+
<div className="flex items-center gap-3">
196+
<div className="flex h-10 w-10 items-center justify-center rounded border bg-background p-2">
197+
<Image
198+
src={integration.logo}
199+
alt={`${integration.name} logo`}
200+
width={24}
201+
height={24}
202+
className="h-6 w-6"
203+
/>
204+
</div>
205+
<div>
206+
<CardTitle className="text-base">{integration.name}</CardTitle>
207+
</div>
208+
</div>
209+
{integration.connected && (
210+
<Badge variant="default" className="text-xs">
211+
<LinkIcon className="mr-1 h-3 w-3" />
212+
Connected
213+
</Badge>
214+
)}
215+
</div>
216+
</CardHeader>
217+
<CardContent className="space-y-4">
218+
<CardDescription className="text-sm leading-relaxed">
219+
{integration.description}
220+
</CardDescription>
221+
222+
<div className="flex items-center justify-between">
223+
{integration.connected ? (
224+
<div className="flex gap-2">
225+
<Button variant="outline" size="sm" disabled>
226+
Configure
227+
</Button>
228+
<Button
229+
variant="ghost"
230+
size="sm"
231+
className="text-destructive hover:text-destructive"
232+
onClick={() => handleDisconnect(integration)}
233+
disabled={disconnectMutation.isPending}
234+
>
235+
{disconnectMutation.isPending ? 'Disconnecting...' : 'Disconnect'}
236+
</Button>
237+
</div>
238+
) : (
239+
<Button
240+
onClick={() => handleConnect(integration)}
241+
size="sm"
242+
className="w-full"
243+
disabled={connectingProvider === integration.id}
244+
>
245+
<PlusIcon className="mr-2 h-4 w-4" />
246+
{connectingProvider === integration.id ? 'Connecting...' : 'Connect'}
247+
</Button>
248+
)}
249+
</div>
250+
</CardContent>
251+
</Card>
252+
))}
253+
</div>
254+
</div>
255+
))}
256+
257+
{integrations.length === 0 && (
258+
<div className="flex h-64 items-center justify-center">
259+
<div className="text-center">
260+
<LinkIcon className="mx-auto h-12 w-12 text-muted-foreground" />
261+
<h3 className="mt-2 font-medium text-foreground text-sm">
262+
No integrations available
263+
</h3>
264+
<p className="mt-1 text-muted-foreground text-sm">
265+
Check back later for new integrations.
266+
</p>
267+
</div>
268+
</div>
269+
)}
270+
</div>
271+
);
272+
}

apps/dashboard/app/(main)/settings/page.tsx

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,14 @@ import {
1515
import { Skeleton } from '@/components/ui/skeleton';
1616
import { ApiKeyCreateDialog, ApiKeyList } from './_components';
1717

18+
const IntegrationsPage = dynamic(
19+
() => import('./integrations/page').then((mod) => ({ default: mod.default })),
20+
{
21+
loading: () => <Skeleton className="h-64 w-full rounded" />,
22+
ssr: false,
23+
}
24+
);
25+
1826
const EmailForm = dynamic(
1927
() =>
2028
import('./_components/email-form').then((mod) => ({
@@ -119,6 +127,11 @@ export default function SettingsPage() {
119127
title: 'API Keys',
120128
description: 'Create and manage API keys for integrations',
121129
};
130+
case 'integrations':
131+
return {
132+
title: 'Integrations',
133+
description: 'Connect your favorite tools and services',
134+
};
122135
default:
123136
return {
124137
title: 'Settings',
@@ -252,6 +265,13 @@ export default function SettingsPage() {
252265
</CardContent>
253266
</Card>
254267
)}
268+
{activeTab === 'integrations' && (
269+
<Card className="shadow-sm">
270+
<CardContent className="pt-6">
271+
<IntegrationsSection />
272+
</CardContent>
273+
</Card>
274+
)}
255275
{activeTab === 'notifications' && (
256276
<div className="flex h-full items-center justify-center">
257277
<div className="text-center">
@@ -308,3 +328,7 @@ function ApiKeysSection() {
308328
</div>
309329
);
310330
}
331+
332+
function IntegrationsSection() {
333+
return <IntegrationsPage />;
334+
}

apps/dashboard/app/api/integrations/vercel/callback/route.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,7 @@ export async function GET(request: NextRequest) {
106106
});
107107

108108
if (existingAccount) {
109+
// Update existing account with new tokens and scope
109110
await db
110111
.update(account)
111112
.set({
@@ -115,6 +116,7 @@ export async function GET(request: NextRequest) {
115116
})
116117
.where(eq(account.id, existingAccount.id));
117118
} else {
119+
// Create new account record
118120
await db.insert(account).values({
119121
id: randomUUID(),
120122
accountId: userInfo.id,
@@ -128,7 +130,8 @@ export async function GET(request: NextRequest) {
128130
}
129131

130132
const redirectUrl =
131-
next || `${process.env.BETTER_AUTH_URL}/dashboard?vercel_integrated=true`;
133+
next ||
134+
`${process.env.BETTER_AUTH_URL}/settings?tab=integrations&vercel_integrated=true`;
132135

133136
return NextResponse.redirect(redirectUrl);
134137
} catch (error) {

apps/dashboard/components/layout/navigation/navigation-config.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,12 @@ export const personalNavigation: NavigationSection[] = [
101101
href: '/settings?tab=api-keys',
102102
rootLevel: true,
103103
},
104+
{
105+
name: 'Integrations',
106+
icon: PlugIcon,
107+
href: '/settings?tab=integrations',
108+
rootLevel: true,
109+
},
104110
],
105111
},
106112
];

0 commit comments

Comments
 (0)