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
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server';
import { getUserFromAuth } from '@kilocode/web-shared/lib/user/server';
import { ensureOrganizationAccess } from '@kilocode/web-shared/routers/organizations/utils';
import { ORGANIZATION_MANAGE_ROLES } from '@kilocode/app-shared/organizations';
import { PLATFORM } from '@/lib/integrations/core/constants';
import { getPlatformOAuthConnectPath } from '@/lib/integrations/oauth/paths';

Expand All @@ -19,9 +20,11 @@ export async function GET(request: NextRequest, context: RouteContext) {
}

try {
await ensureOrganizationAccess({ user }, id);
await ensureOrganizationAccess({ user }, id, ORGANIZATION_MANAGE_ROLES);
} catch {
return NextResponse.redirect(new URL('/integrations?error=unauthorized', request.url));
return NextResponse.redirect(
new URL(`/organizations/${id}/integrations/slack?error=permission_required`, request.url)
);
}

return NextResponse.redirect(
Expand Down
11 changes: 8 additions & 3 deletions apps/web/src/components/integrations/IntegrationDetailPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import Link from 'next/link';
import { notFound } from 'next/navigation';
import { ArrowLeft } from 'lucide-react';
import { getUserFromAuthOrRedirect } from '@kilocode/web-shared/lib/user/server';
import type { OrganizationRole } from '@kilocode/web-shared/lib/organizations/organization-types';
import { PLATFORM } from '@/lib/integrations/core/constants';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
Expand All @@ -23,6 +24,7 @@ export type IntegrationDetailSearchParams = Promise<{

type DetailRenderProps = {
organizationId?: string;
role?: OrganizationRole;
search: Awaited<IntegrationDetailSearchParams>;
};

Expand Down Expand Up @@ -90,14 +92,15 @@ const integrationDetailRegistry = {
title: 'Slack Integration',
userSubtitle: 'Connect your Slack workspace to receive notifications',
organizationSubtitle: organizationName => `Manage Slack integration for ${organizationName}`,
render: async ({ organizationId, search }) => {
render: async ({ organizationId, search, role }) => {
const { SlackIntegrationDetails } =
await import('@/components/integrations/SlackIntegrationDetails');
return (
<SlackIntegrationDetails
organizationId={organizationId}
success={search.success === 'installed'}
error={search.error}
canManage={!role || role === 'owner' || role === 'admin'}
/>
);
},
Expand Down Expand Up @@ -203,9 +206,10 @@ function IntegrationDetailsFallback() {
async function PlatformIntegrationDetails({
platform,
organizationId,
role,
search,
}: DetailRenderProps & { platform: IntegrationDetailPlatform }) {
return getIntegrationDetailEntry(platform).render({ organizationId, search });
return getIntegrationDetailEntry(platform).render({ organizationId, role, search });
}

function SuspendedIntegrationDetails(
Expand Down Expand Up @@ -268,7 +272,7 @@ export async function OrganizationIntegrationDetailPage({
return (
<OrganizationByPageLayout
params={params}
render={({ organization }) => (
render={({ role, organization }) => (
<>
<div className="space-y-4">
<BackLink
Expand All @@ -282,6 +286,7 @@ export async function OrganizationIntegrationDetailPage({
<SuspendedIntegrationDetails
platform={detailPlatform}
organizationId={organization.id}
role={role}
search={{ ...search, returnTo: returnTo ?? undefined }}
/>
</>
Expand Down
110 changes: 69 additions & 41 deletions apps/web/src/components/integrations/SlackIntegrationDetails.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,17 @@ type SlackIntegrationDetailsProps = {
organizationId?: string;
success?: boolean;
error?: string;
/** Organization members without management authority get a read-only view. */
canManage?: boolean;
};

const slackConnectionErrorMessages: Record<string, string> = {
workspace_already_connected:
'This Slack workspace is already connected to another Kilo account or organization. Disconnect it there before connecting it here.',
permission_required:
'Only organization owners and admins can connect or change the organization Slack integration.',
organization_access_required:
'You no longer have access to this organization. Ask an organization owner or admin for access.',
};

const duplicateSlackWorkspaceMigration = 'duplicate_slack_workspace_migration';
Expand All @@ -52,6 +58,7 @@ export function SlackIntegrationDetails({
organizationId,
success,
error,
canManage = true,
}: SlackIntegrationDetailsProps) {
const trpc = useTRPC();
const queryClient = useQueryClient();
Expand Down Expand Up @@ -289,14 +296,16 @@ export function SlackIntegrationDetails({
This Slack installation is missing required permissions. Re-install the Slack app to
refresh its scopes.
</p>
<Button
onClick={handleInstall}
disabled={isStartingSlackConnection}
className="bg-yellow-400 text-yellow-950 hover:bg-yellow-300"
>
<RefreshCw className="mr-2 h-4 w-4" />
{isStartingSlackConnection ? 'Loading...' : 'Re-install'}
</Button>
{canManage && (
<Button
onClick={handleInstall}
disabled={isStartingSlackConnection}
className="bg-yellow-400 text-yellow-950 hover:bg-yellow-300"
>
<RefreshCw className="mr-2 h-4 w-4" />
{isStartingSlackConnection ? 'Loading...' : 'Re-install'}
</Button>
)}
</AlertDescription>
</Alert>
)}
Expand All @@ -307,14 +316,26 @@ export function SlackIntegrationDetails({
<AlertTitle>Slack integration suspended</AlertTitle>
<AlertDescription className="gap-3">
<p>{getSuspendedSlackIntegrationMessage(installation.suspendedBy)}</p>
<Button
onClick={handleInstall}
disabled={isStartingSlackConnection}
className="bg-yellow-400 text-yellow-950 hover:bg-yellow-300"
>
<RefreshCw className="mr-2 h-4 w-4" />
{isStartingSlackConnection ? 'Loading...' : 'Re-install Slack'}
</Button>
{canManage && (
<Button
onClick={handleInstall}
disabled={isStartingSlackConnection}
className="bg-yellow-400 text-yellow-950 hover:bg-yellow-300"
>
<RefreshCw className="mr-2 h-4 w-4" />
{isStartingSlackConnection ? 'Loading...' : 'Re-install Slack'}
</Button>
)}
</AlertDescription>
</Alert>
)}

{!canManage && (
<Alert>
<AlertDescription>
Only organization owners and admins can connect, re-install, or disconnect this Slack
integration.
{installation && !isSuspended && ' You can still test the connection.'}
</AlertDescription>
</Alert>
)}
Expand Down Expand Up @@ -401,34 +422,39 @@ export function SlackIntegrationDetails({
onValueChange={handleModelChange}
isLoading={isLoadingModels}
placeholder="Select a model"
disabled={!canManage}
/>
</div>

<div className="space-y-2">
<div className="flex flex-wrap gap-3">
<Button
variant="outline"
onClick={handleInstall}
disabled={isStartingSlackConnection}
title="Re-run the Slack OAuth flow to refresh scopes and permissions"
>
<RefreshCw className="mr-2 h-4 w-4" />
{isStartingSlackConnection ? 'Loading...' : 'Re-install'}
</Button>
{canManage && (
<Button
variant="outline"
onClick={handleInstall}
disabled={isStartingSlackConnection}
title="Re-run the Slack OAuth flow to refresh scopes and permissions"
>
<RefreshCw className="mr-2 h-4 w-4" />
{isStartingSlackConnection ? 'Loading...' : 'Re-install'}
</Button>
)}
{!isSuspended && (
<TestConnectionButton
isPending={testConnection.isPending}
state={connectionCheck}
onClick={handleTestConnection}
/>
)}
<Button
variant="destructive"
onClick={handleUninstall}
disabled={uninstallApp.isPending}
>
{uninstallApp.isPending ? 'Disconnecting...' : 'Disconnect'}
</Button>
{canManage && (
<Button
variant="destructive"
onClick={handleUninstall}
disabled={uninstallApp.isPending}
>
{uninstallApp.isPending ? 'Disconnecting...' : 'Disconnect'}
</Button>
)}
{IS_DEVELOPMENT && (
<Button
variant="outline"
Expand Down Expand Up @@ -476,15 +502,17 @@ export function SlackIntegrationDetails({
</ul>
</div>

<Button
onClick={handleInstall}
size="lg"
className="w-full"
disabled={isStartingSlackConnection}
>
<MessageSquare className="mr-2 h-4 w-4" />
{isStartingSlackConnection ? 'Loading...' : 'Connect Slack'}
</Button>
{canManage && (
<Button
onClick={handleInstall}
size="lg"
className="w-full"
disabled={isStartingSlackConnection}
>
<MessageSquare className="mr-2 h-4 w-4" />
{isStartingSlackConnection ? 'Loading...' : 'Connect Slack'}
</Button>
)}
</>
)}
</CardContent>
Expand Down
27 changes: 17 additions & 10 deletions apps/web/src/lib/integrations/oauth/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -234,19 +234,26 @@ export async function handleStatefulPlatformOAuthConnect(

return NextResponse.redirect(buildOAuthUrl(state));
} catch (error) {
captureException(error, {
tags: {
endpoint: `${platform}/connect`,
source,
},
extra: {
organizationId,
},
});
const denialCode = organizationAccessDenialErrorCode(error);
if (!denialCode) {
captureException(error, {
tags: {
endpoint: `${platform}/connect`,
source,
},
extra: {
organizationId,
},
});
}

return NextResponse.redirect(
new URL(
buildIntegrationOAuthConnectErrorPath(platform, organizationId, 'oauth_init_failed'),
buildIntegrationOAuthConnectErrorPath(
platform,
organizationId,
denialCode ?? 'oauth_init_failed'
),
APP_URL
)
);
Expand Down
Loading