Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 13 additions & 4 deletions src/app/(app)/integrations/IntegrationsPageClient.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import { UserGitHubAppsProvider } from '@/components/integrations/UserGitHubApps
import { useGitHubAppsQueries } from '@/components/integrations/GitHubAppsContext';
import { UserSlackProvider } from '@/components/integrations/UserSlackProvider';
import { useSlackQueries } from '@/components/integrations/SlackContext';
import { UserDiscordProvider } from '@/components/integrations/UserDiscordProvider';
import { useDiscordQueries } from '@/components/integrations/DiscordContext';
import { UserGitLabProvider } from '@/components/integrations/UserGitLabProvider';
import { useGitLabQueries } from '@/components/integrations/GitLabContext';
import { PageContainer } from '@/components/layouts/PageContainer';
Expand All @@ -19,6 +21,7 @@ function IntegrationsPageContent() {
const router = useRouter();
const { queries: githubQueries } = useGitHubAppsQueries();
const { queries: slackQueries } = useSlackQueries();
const { queries: discordQueries } = useDiscordQueries();
const { queries: gitlabQueries } = useGitLabQueries();

// Fetch GitHub App installation status
Expand All @@ -27,10 +30,13 @@ function IntegrationsPageContent() {
// Fetch Slack installation status
const { data: slackInstallation, isLoading: slackLoading } = slackQueries.getInstallation();

// Fetch Discord installation status
const { data: discordInstallation, isLoading: discordLoading } = discordQueries.getInstallation();

// Fetch GitLab installation status
const { data: gitlabInstallation, isLoading: gitlabLoading } = gitlabQueries.getInstallation();

const isLoading = githubLoading || slackLoading || gitlabLoading;
const isLoading = githubLoading || slackLoading || discordLoading || gitlabLoading;

if (isLoading) {
return (
Expand All @@ -54,6 +60,7 @@ function IntegrationsPageContent() {
const platforms = buildPlatformsForPersonal({
github: githubInstallation,
slack: slackInstallation,
discord: discordInstallation,
gitlab: gitlabInstallation,
});

Expand Down Expand Up @@ -85,9 +92,11 @@ export function IntegrationsPageClient() {
return (
<UserGitHubAppsProvider>
<UserSlackProvider>
<UserGitLabProvider>
<IntegrationsPageContent />
</UserGitLabProvider>
<UserDiscordProvider>
<UserGitLabProvider>
<IntegrationsPageContent />
</UserGitLabProvider>
</UserDiscordProvider>
</UserSlackProvider>
</UserGitHubAppsProvider>
);
Expand Down
56 changes: 56 additions & 0 deletions src/app/(app)/integrations/discord/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { Suspense } from 'react';
import { getUserFromAuthOrRedirect } from '@/lib/user.server';
import { DiscordIntegrationDetails } from '@/components/integrations/DiscordIntegrationDetails';
import { UserDiscordProvider } from '@/components/integrations/UserDiscordProvider';
import { Button } from '@/components/ui/button';
import { ArrowLeft } from 'lucide-react';
import Link from 'next/link';
import { Card, CardContent } from '@/components/ui/card';
import { PageLayout } from '@/components/PageLayout';

export default async function UserDiscordIntegrationPage({
searchParams,
}: {
searchParams: Promise<{
success?: string;
error?: string;
}>;
}) {
await getUserFromAuthOrRedirect('/users/sign_in');
const search = await searchParams;

return (
<PageLayout
title="Discord Integration"
subtitle="Connect your Discord server to interact with Kilo"
headerActions={
<Link href="/integrations">
<Button variant="ghost" size="sm" className="gap-2">
<ArrowLeft className="h-4 w-4" />
Back to Integrations
</Button>
</Link>
}
>
<UserDiscordProvider>
<Suspense
fallback={
<Card>
<CardContent className="pt-6">
<div className="animate-pulse space-y-4">
<div className="bg-muted h-20 rounded" />
<div className="bg-muted h-32 rounded" />
</div>
</CardContent>
</Card>
}
>
<DiscordIntegrationDetails
success={search.success === 'installed'}
error={search.error}
/>
</Suspense>
</UserDiscordProvider>
</PageLayout>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import { OrgGitHubAppsProvider } from '@/components/integrations/OrgGitHubAppsPr
import { useGitHubAppsQueries } from '@/components/integrations/GitHubAppsContext';
import { OrgSlackProvider } from '@/components/integrations/OrgSlackProvider';
import { useSlackQueries } from '@/components/integrations/SlackContext';
import { OrgDiscordProvider } from '@/components/integrations/OrgDiscordProvider';
import { useDiscordQueries } from '@/components/integrations/DiscordContext';
import { OrgGitLabProvider } from '@/components/integrations/OrgGitLabProvider';
import { useGitLabQueries } from '@/components/integrations/GitLabContext';

Expand All @@ -22,6 +24,7 @@ function IntegrationsPageContent({ organizationId }: IntegrationsPageClientProps
const router = useRouter();
const { queries: githubQueries } = useGitHubAppsQueries();
const { queries: slackQueries } = useSlackQueries();
const { queries: discordQueries } = useDiscordQueries();
const { queries: gitlabQueries } = useGitLabQueries();

// Fetch GitHub App installation status
Expand All @@ -30,10 +33,13 @@ function IntegrationsPageContent({ organizationId }: IntegrationsPageClientProps
// Fetch Slack installation status
const { data: slackInstallation, isLoading: slackLoading } = slackQueries.getInstallation();

// Fetch Discord installation status
const { data: discordInstallation, isLoading: discordLoading } = discordQueries.getInstallation();

// Fetch GitLab installation status
const { data: gitlabInstallation, isLoading: gitlabLoading } = gitlabQueries.getInstallation();

const isLoading = githubLoading || slackLoading || gitlabLoading;
const isLoading = githubLoading || slackLoading || discordLoading || gitlabLoading;

if (isLoading) {
return (
Expand All @@ -55,6 +61,7 @@ function IntegrationsPageContent({ organizationId }: IntegrationsPageClientProps
const platforms = buildPlatformsForOrg(organizationId, {
github: githubInstallation,
slack: slackInstallation,
discord: discordInstallation,
gitlab: gitlabInstallation,
});

Expand All @@ -78,9 +85,11 @@ export function IntegrationsPageClient({ organizationId }: IntegrationsPageClien
return (
<OrgGitHubAppsProvider organizationId={organizationId}>
<OrgSlackProvider organizationId={organizationId}>
<OrgGitLabProvider organizationId={organizationId}>
<IntegrationsPageContent organizationId={organizationId} />
</OrgGitLabProvider>
<OrgDiscordProvider organizationId={organizationId}>
<OrgGitLabProvider organizationId={organizationId}>
<IntegrationsPageContent organizationId={organizationId} />
</OrgGitLabProvider>
</OrgDiscordProvider>
</OrgSlackProvider>
</OrgGitHubAppsProvider>
);
Expand Down
66 changes: 66 additions & 0 deletions src/app/(app)/organizations/[id]/integrations/discord/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { Suspense } from 'react';
import { DiscordIntegrationDetails } from '@/components/integrations/DiscordIntegrationDetails';
import { OrgDiscordProvider } from '@/components/integrations/OrgDiscordProvider';
import { Button } from '@/components/ui/button';
import { ArrowLeft } from 'lucide-react';
import Link from 'next/link';
import { Card, CardContent } from '@/components/ui/card';
import { OrganizationByPageLayout } from '@/components/organizations/OrganizationByPageLayout';

export default async function OrgDiscordIntegrationPage({
params,
searchParams,
}: {
params: Promise<{ id: string }>;
searchParams: Promise<{
success?: string;
error?: string;
}>;
}) {
const search = await searchParams;

return (
<OrganizationByPageLayout
params={params}
render={({ organization }) => (
<>
<div className="space-y-4">
<Link href={`/organizations/${organization.id}/integrations`}>
<Button variant="ghost" size="sm" className="gap-2">
<ArrowLeft className="h-4 w-4" />
Back to Integrations
</Button>
</Link>
<div>
<h1 className="text-3xl font-bold">Discord Integration</h1>
<p className="text-muted-foreground mt-2">
Manage Discord integration for {organization.name}
</p>
</div>
</div>

<OrgDiscordProvider organizationId={organization.id}>
<Suspense
fallback={
<Card>
<CardContent className="pt-6">
<div className="animate-pulse space-y-4">
<div className="bg-muted h-20 rounded" />
<div className="bg-muted h-32 rounded" />
</div>
</CardContent>
</Card>
}
>
<DiscordIntegrationDetails
organizationId={organization.id}
success={search.success === 'installed'}
error={search.error}
/>
</Suspense>
</OrgDiscordProvider>
</>
)}
/>
);
}
156 changes: 156 additions & 0 deletions src/app/api/integrations/discord/callback/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server';
import { getUserFromAuth } from '@/lib/user.server';
import { ensureOrganizationAccess } from '@/routers/organizations/utils';
import type { Owner } from '@/lib/integrations/core/types';
import { captureException, captureMessage } from '@sentry/nextjs';
import { exchangeDiscordCode, upsertDiscordInstallation } from '@/lib/integrations/discord-service';
import { verifyOAuthState } from '@/lib/integrations/oauth-state';
import { APP_URL } from '@/lib/constants';

const buildDiscordRedirectPath = (state: string | null, queryParam: string): string => {
// Try to extract the owner from a signed state for best-effort redirects on error paths.
// We use verifyOAuthState so we don't trust unsigned/tampered values for routing.
const verified = state ? verifyOAuthState(state) : null;
const owner = verified?.owner;

if (owner?.startsWith('org_')) {
return `/organizations/${owner.replace('org_', '')}/integrations/discord?${queryParam}`;
}
if (owner?.startsWith('user_')) {
return `/integrations/discord?${queryParam}`;
}
return `/integrations?${queryParam}`;
};

/**
* Discord OAuth Callback
*
* Called when user completes the Discord OAuth flow
*/
export async function GET(request: NextRequest) {
try {
// 1. Verify user authentication
const { user, authFailedResponse } = await getUserFromAuth({ adminOnly: false });
if (authFailedResponse) {
return NextResponse.redirect(new URL('/users/sign_in', APP_URL));
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: Auth redirect drops OAuth callback context, causing non-recoverable install failures for signed-out users.

When getUserFromAuth fails, this redirects to /users/sign_in without preserving the current callback URL (code/state). If a user’s session expires between initiating OAuth and callback, they authenticate successfully but cannot resume the install because the authorization code/state are lost. Consider redirecting to sign-in with a callbackPath back to this callback URL so the flow can continue safely.

}

// 2. Extract parameters
const searchParams = request.nextUrl.searchParams;
const code = searchParams.get('code');
const state = searchParams.get('state');
const error = searchParams.get('error');

// Handle OAuth errors from Discord
if (error) {
captureMessage('Discord OAuth error', {
level: 'warning',
tags: { endpoint: 'discord/callback', source: 'discord_oauth' },
extra: { error, state },
});

return NextResponse.redirect(
new URL(buildDiscordRedirectPath(state, `error=${encodeURIComponent(error)}`), APP_URL)
);
}

// Validate code is present
if (!code) {
captureMessage('Discord callback missing code', {
level: 'warning',
tags: { endpoint: 'discord/callback', source: 'discord_oauth' },
extra: { state, allParams: Object.fromEntries(searchParams.entries()) },
});

return NextResponse.redirect(
new URL(buildDiscordRedirectPath(state, 'error=missing_code'), APP_URL)
);
}

// 3. Verify signed state (CSRF protection)
const verified = verifyOAuthState(state);
if (!verified) {
captureMessage('Discord callback invalid or tampered state signature', {
level: 'warning',
tags: { endpoint: 'discord/callback', source: 'discord_oauth' },
extra: { code: '***', state, allParams: Object.fromEntries(searchParams.entries()) },
});
return NextResponse.redirect(new URL('/integrations?error=invalid_state', APP_URL));
}

// 4. Verify the user completing the flow is the same user who initiated it
if (verified.userId !== user.id) {
captureMessage('Discord callback user mismatch (possible CSRF)', {
level: 'warning',
tags: { endpoint: 'discord/callback', source: 'discord_oauth' },
extra: { stateUserId: verified.userId, sessionUserId: user.id },
});
return NextResponse.redirect(new URL('/integrations?error=unauthorized', APP_URL));
}

// 5. Parse owner from verified state payload
let owner: Owner;
const ownerStr = verified.owner;

if (ownerStr.startsWith('org_')) {
const ownerId = ownerStr.replace('org_', '');
owner = { type: 'org', id: ownerId };
} else if (ownerStr.startsWith('user_')) {
const ownerId = ownerStr.replace('user_', '');
owner = { type: 'user', id: ownerId };
} else {
captureMessage('Discord callback missing or invalid owner in state', {
level: 'warning',
tags: { endpoint: 'discord/callback', source: 'discord_oauth' },
extra: { code: '***', owner: ownerStr },
});
return NextResponse.redirect(new URL('/integrations?error=invalid_state', APP_URL));
}

// 6. Verify user has access to the owner
if (owner.type === 'org') {
await ensureOrganizationAccess({ user }, owner.id);
} else {
// For user-owned integrations, verify it's the same user
if (user.id !== owner.id) {
return NextResponse.redirect(new URL('/integrations?error=unauthorized', APP_URL));
}
}

// 7. Exchange code for access token
const oauthData = await exchangeDiscordCode(code);

// 8. Store installation in database
await upsertDiscordInstallation(owner, oauthData);

// 9. Redirect to success page
const successPath =
owner.type === 'org'
? `/organizations/${owner.id}/integrations/discord?success=installed`
: `/integrations/discord?success=installed`;

return NextResponse.redirect(new URL(successPath, APP_URL));
} catch (error) {
console.error('Error handling Discord OAuth callback:', error);

// Capture error to Sentry with context for debugging
const searchParams = request.nextUrl.searchParams;
const state = searchParams.get('state');

captureException(error, {
tags: {
endpoint: 'discord/callback',
source: 'discord_oauth',
},
extra: {
state,
hasCode: !!searchParams.get('code'),
},
});

return NextResponse.redirect(
new URL(buildDiscordRedirectPath(state, 'error=installation_failed'), APP_URL)
);
}
}
Loading