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
5 changes: 5 additions & 0 deletions .env.local.example
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,8 @@ DATABASE_URL=postgresql://postgres:[YOUR-POSTGRES-PASSWORD]@[YOUR-SUPABASE-DB-HO
# Clerk Custom Sign-In/Up URLs
NEXT_PUBLIC_CLERK_SIGN_IN_URL=/sign-in
NEXT_PUBLIC_CLERK_SIGN_UP_URL=/sign-up

# SkyFi MCP Configuration
# Users connect their own accounts via OAuth 2.1 in Settings.
# The app redirect URI is built using NEXT_PUBLIC_APP_URL.
NEXT_PUBLIC_APP_URL=http://localhost:3000
7 changes: 7 additions & 0 deletions app/actions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { Chat, AIMessage } from '@/lib/types'
import { UserMessage } from '@/components/user-message'
import { BotMessage } from '@/components/message'
import { SearchSection } from '@/components/search-section'
import { SkyfiSection } from '@/components/skyfi-section'
import SearchRelated from '@/components/search-related'
import { GeoJsonLayer } from '@/components/map/geojson-layer'
import { ResolutionCarousel } from '@/components/resolution-carousel'
Expand Down Expand Up @@ -853,6 +854,12 @@ export const getUIStateFromAIState = (aiState: AIState): UIState => {
)
searchResults.done(JSON.stringify(toolOutput))
switch (name) {
case 'skyfiQueryTool':
return {
id,
component: <SkyfiSection result={searchResults.value} />,
isCollapsed: isCollapsed.value
}
case 'search':
return {
id,
Expand Down
101 changes: 101 additions & 0 deletions app/api/skyfi/callback/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { NextResponse, NextRequest } from 'next/server';
import { getCurrentUserIdOnServer } from '@/lib/auth/get-current-user';
import { SkyfiOAuthProvider } from '@/lib/skyfi/provider';
import { getRedirectUri } from '@/lib/actions/skyfi';
import { db } from '@/lib/db';
import { skyfiOAuthTokens } from '@/lib/db/schema';
import { eq } from 'drizzle-orm';

export async function GET(request: NextRequest) {
try {
const userId = await getCurrentUserIdOnServer();
if (!userId) {
return NextResponse.json({ error: 'Unauthorized: No user session found.' }, { status: 401 });
}

const { searchParams } = new URL(request.url);
const code = searchParams.get('code');
const state = searchParams.get('state');

if (!code || !state) {
return NextResponse.json({ error: 'Missing code or state parameters.' }, { status: 400 });
}

const redirectUri = await getRedirectUri();
const provider = new SkyfiOAuthProvider(userId, redirectUri);

const storedState = await provider.state();
if (state !== storedState) {
return NextResponse.json({ error: 'CSRF State mismatch.' }, { status: 400 });
}

const clientInfo = await provider.clientInformation();
const verifier = await provider.codeVerifier();

if (!clientInfo?.client_id || !verifier) {
return NextResponse.json({ error: 'Missing client registration or PKCE verifier.' }, { status: 400 });
}

console.log('[SkyFiCallback] Exchanging code for tokens with a 10s timeout...');

const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 10000);

try {
const response = await fetch('https://mcp.skyfi.com/oauth/token', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
grant_type: 'authorization_code',
client_id: clientInfo.client_id,
code,
redirect_uri: redirectUri,
code_verifier: verifier,
}),
signal: controller.signal,
});

clearTimeout(timeoutId);

if (!response.ok) {
const errorText = await response.text();
console.error('[SkyFiCallback] Token exchange failed:', errorText);
return NextResponse.json({ error: `Token exchange failed: ${errorText}` }, { status: 500 });
}

const data = await response.json();
console.log('[SkyFiCallback] Code successfully exchanged for tokens.');

// Save tokens in database
await provider.saveTokens({
access_token: data.access_token,
refresh_token: data.refresh_token || undefined,
expires_at: data.expires_in ? Math.floor(Date.now() / 1000) + data.expires_in : undefined,
});

// Clear temporary state and code verifier
await db.update(skyfiOAuthTokens)
.set({
state: null,
codeVerifier: null,
updatedAt: new Date(),
})
.where(eq(skyfiOAuthTokens.userId, userId));

} catch (fetchError: any) {
clearTimeout(timeoutId);
console.error('[SkyFiCallback] Fetch failed or timed out:', fetchError.message);
return NextResponse.json({ error: `Token exchange failed or timed out: ${fetchError.message}` }, { status: 500 });
}

// Redirect user back to the settings page
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000';
return NextResponse.redirect(`${baseUrl}/settings`);
} catch (error: any) {
console.error('[SkyFiCallback] Unexpected error:', error.message);
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000';
return NextResponse.redirect(`${baseUrl}/settings?error=skyfi_callback_failed`);
}
}
11 changes: 11 additions & 0 deletions components/settings/components/settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,17 @@ export function Settings({ initialTab = "system-prompt" }: SettingsProps) {
<Tabs.Trigger value="tool" className="inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 bg-primary text-primary-foreground hover:bg-primary/90 h-10 px-4 py-2 data-[state=active]:bg-primary/80">Tools</Tabs.Trigger>
<Tabs.Trigger value="user-management" className="inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 bg-primary text-primary-foreground hover:bg-primary/90 h-10 px-4 py-2 data-[state=active]:bg-primary/80">Users</Tabs.Trigger>
<Tabs.Trigger value="map" className="inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 bg-primary text-primary-foreground hover:bg-primary/90 h-10 px-4 py-2 data-[state=active]:bg-primary/80">Map</Tabs.Trigger>
{/*
ACCOUNT TAB — DO NOT ADD A UI BUTTON FOR DISCORD HERE.
Discord connect/disconnect is managed entirely in the backend via
Clerk OAuth (`createExternalAccount` / `destroy`). It is invoked
programmatically when needed (e.g. from bot commands or API flows)
and must NOT be exposed as a visible button or tab in the Settings UI.
Removing this trigger hides the tab from the UI while preserving
the full Discord linking functionality (card, connect, disconnect)
below for future programmatic or background use.
*/}
<Tabs.Trigger value="account" className="hidden" aria-hidden="true" tabIndex={-1} disabled>Account</Tabs.Trigger>
</Tabs.List>
<AnimatePresence mode="wait">
<motion.div
Expand Down
151 changes: 150 additions & 1 deletion components/settings/components/tool-selection-form.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"use client";

import { useState, useEffect } from "react";
import type { UseFormReturn } from "react-hook-form";
import {
FormField,
Expand All @@ -19,7 +20,11 @@ import {
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Earth, Orbit } from "lucide-react";
import { Button } from "@/components/ui/button";
import { startSkyfiConnection, getSkyfiConnectionStatus, disconnectSkyfi } from "@/lib/actions/skyfi";
import { Earth, Orbit, ShieldCheck, Loader2 } from "lucide-react";
import { useToast } from "@/components/ui/hooks/use-toast";
import { cn } from "@/lib/utils";

interface ToolSelectionFormProps {
form: UseFormReturn<any>;
Expand All @@ -45,6 +50,32 @@ const tools = [
];

export function ToolSelectionForm({ form }: ToolSelectionFormProps) {
const selectedModel = form.watch("selectedModel");
const { toast } = useToast();
const [skyfiConnected, setSkyfiConnected] = useState(false);
const [skyfiBudget, setSkyfiBudget] = useState<string | null>(null);
const [loadingStatus, setLoadingStatus] = useState(true);
const [isConnecting, setIsConnecting] = useState(false);
const [isDisconnecting, setIsDisconnecting] = useState(false);

useEffect(() => {
async function loadStatus() {
if (selectedModel === "SkyFi") {
setLoadingStatus(true);
try {
const status = await getSkyfiConnectionStatus();
setSkyfiConnected(status.connected);
setSkyfiBudget(status.budget || null);
} catch (err) {
console.error("Failed to load SkyFi connection status:", err);
} finally {
setLoadingStatus(false);
}
}
}
loadStatus();
}, [selectedModel]);

return (
<FormField
control={form.control}
Expand Down Expand Up @@ -123,6 +154,124 @@ export function ToolSelectionForm({ form }: ToolSelectionFormProps) {
characteristics.
</FormDescription>
<FormMessage />

{selectedModel === "SkyFi" && (
<Card className="mt-4 border-2 border-primary/20 bg-muted/20">
<CardContent className="p-4 space-y-4">
<div className="flex items-center gap-2 text-primary font-semibold">
<Orbit className="h-5 w-5 animate-pulse" />
<h4>SkyFi Account Connection</h4>
</div>
{loadingStatus ? (
<div className="flex items-center gap-2 text-muted-foreground text-sm py-2">
<Loader2 className="h-4 w-4 animate-spin" />
Checking SkyFi connection status...
</div>
) : skyfiConnected ? (
<div className="space-y-4">
<div className="flex items-center gap-4 p-4 border rounded-xl bg-emerald-500/10 border-emerald-500/20 text-emerald-600 dark:text-emerald-400">
<ShieldCheck className="h-10 w-10 shrink-0" />
<div className="flex-1 space-y-1">
<p className="font-semibold text-foreground">SkyFi Account Linked</p>
{skyfiBudget ? (
<div className="text-xs text-muted-foreground font-mono bg-card p-2 rounded-lg max-h-[100px] overflow-y-auto">
{skyfiBudget}
</div>
) : (
<p className="text-xs text-muted-foreground">Successfully authenticated and ready to query satellite imagery.</p>
)}
</div>
<Button
type="button"
variant="destructive"
size="sm"
disabled={isDisconnecting}
onClick={async () => {
setIsDisconnecting(true);
try {
const res = await disconnectSkyfi();
if (res.success) {
setSkyfiConnected(false);
setSkyfiBudget(null);
toast({
title: "SkyFi Disconnected",
description: "Your SkyFi account has been disconnected.",
});
} else {
throw new Error(res.error);
}
} catch (err: any) {
toast({
title: "Disconnection Error",
description: err.message || "Failed to disconnect SkyFi.",
variant: "destructive",
});
} finally {
setIsDisconnecting(false);
}
}}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
>
{isDisconnecting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Disconnecting...
</>
) : (
"Disconnect"
)}
</Button>
</div>
</div>
) : (
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
Connect your SkyFi account to search existing satellite imagery, task new captures, and manage Areas of Interest (AOIs) directly from your chats.
</p>
<Button
type="button"
disabled={isConnecting}
className="bg-[#000000] hover:bg-[#333333] text-white flex items-center gap-2 px-6 py-5 text-base font-semibold"
onClick={async () => {
setIsConnecting(true);
try {
const res = await startSkyfiConnection();
if (res.authRequired) {
window.location.href = `/sign-in?redirect_url=${encodeURIComponent(window.location.href)}`;
return;
}
if (res.error) {
throw new Error(res.error);
}
if (res.url) {
window.location.href = res.url;
}
} catch (err: any) {
toast({
title: "Connection Error",
description: err.message || "Failed to initiate SkyFi connection.",
variant: "destructive",
});
setIsConnecting(false);
}
}}
>
{isConnecting ? (
<>
<Loader2 className="h-5 w-5 animate-spin mr-2" />
<span>Initiating OAuth...</span>
</>
) : (
<>
<Orbit className="h-5 w-5 mr-2" />
<span>Connect SkyFi Account</span>
</>
)}
</Button>
</div>
)}
</CardContent>
</Card>
)}
</FormItem>
)}
/>
Expand Down
Loading