Skip to content
Open
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
3 changes: 3 additions & 0 deletions .env.embeddings.example
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,6 @@ GCP_CREDENTIALS_PATH=/path/to/your/gcp_credentials.json
# Optional: Path to the AlphaEarth index file
# Defaults to ./aef_index.csv if not specified
AEF_INDEX_PATH=./aef_index.csv

# Firecrawl API Key for business domain scraping features
# FIRECRAWL_API_KEY=your_firecrawl_api_key
5 changes: 0 additions & 5 deletions .env.local.example
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,3 @@ 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: 0 additions & 7 deletions app/actions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@ 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 @@ -854,12 +853,6 @@ 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: 0 additions & 101 deletions app/api/skyfi/callback/route.ts

This file was deleted.

6 changes: 5 additions & 1 deletion components/profile-toggle.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
'use client'
import { Settings, Shield, CircleUserRound, LogOut } from "lucide-react"
import { User, Settings, Shield, CircleUserRound, LogOut } from "lucide-react"
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
import { Button } from "@/components/ui/button"
import { ProfileToggleEnum, useProfileToggle } from "./profile-toggle-context"
Expand Down Expand Up @@ -57,6 +57,10 @@ export function ProfileToggle() {
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-56" align="start" forceMount>
<DropdownMenuItem onClick={() => handleSectionToggle(ProfileToggleEnum.Account)} data-testid="profile-account">
<User className="mr-2 h-4 w-4" />
<span>Account</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleSectionToggle(ProfileToggleEnum.Settings)} data-testid="profile-settings">
<Settings className="mr-2 h-4 w-4" />
<span>Settings</span>
Expand Down
145 changes: 128 additions & 17 deletions components/settings/components/settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import { useSettingsStore, MapProvider } from "@/lib/store/settings";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Label } from "@/components/ui/label";
import { useToast } from "@/components/ui/hooks/use-toast"
import { getSystemPrompt, saveSystemPrompt } from "../../../lib/actions/chat"
import { getSystemPrompt, saveSystemPrompt, getUserDomain, saveUserDomain } from "../../../lib/actions/chat"
import { getSelectedModel, saveSelectedModel } from "../../../lib/actions/users"
import { useCurrentUser } from "@/lib/auth/use-current-user"
import { SettingsSkeleton } from './settings-skeleton'
Expand Down Expand Up @@ -49,7 +49,34 @@ const settingsFormSchema = z.object({
),
newUserEmail: z.string().email().optional().or(z.literal('')),
newUserRole: z.enum(["admin", "editor", "viewer"]).optional(),
domain: z.string().url().optional().or(z.literal('')),
domain: z.string().optional().or(z.literal('')).refine((val) => {
if (!val) return true;
try {
const url = val.startsWith('http') ? val : `https://${val}`;
const parsed = new URL(url);
const hostname = parsed.hostname;

// Explicitly reject localhost and internal/local hostnames
if (hostname === 'localhost') return false;
if (hostname.endsWith('.local') || hostname.endsWith('.internal')) return false;

// Check private IP ranges: 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, and 127.0.0.1
const ipv4Regex = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/;
const match = hostname.match(ipv4Regex);
if (match) {
const octet1 = parseInt(match[1], 10);
const octet2 = parseInt(match[2], 10);
if (octet1 === 10) return false;
if (octet1 === 172 && octet2 >= 16 && octet2 <= 31) return false;
if (octet1 === 192 && octet2 === 168) return false;
if (octet1 === 127) return false; // Loopback
}

return true;
} catch (e) {
return false;
}
}, { message: "Invalid domain or URL. Only public domains/URLs are allowed." }),
})

export type SettingsFormValues = z.infer<typeof settingsFormSchema>
Expand Down Expand Up @@ -97,9 +124,10 @@ export function Settings({ initialTab = "system-prompt" }: SettingsProps) {
async function fetchData() {
if (!userId || authLoading) return;

const [existingPrompt, selectedModel] = await Promise.all([
const [existingPrompt, selectedModel, existingDomain] = await Promise.all([
getSystemPrompt(userId),
getSelectedModel(),
getUserDomain(userId),
]);

if (existingPrompt) {
Expand All @@ -108,6 +136,9 @@ export function Settings({ initialTab = "system-prompt" }: SettingsProps) {
if (selectedModel) {
form.setValue("selectedModel", selectedModel, { shouldValidate: true, shouldDirty: false });
}
if (existingDomain) {
form.setValue("domain", existingDomain, { shouldValidate: true, shouldDirty: false });
}
}
fetchData();
}, [form, userId, authLoading]);
Expand All @@ -129,10 +160,11 @@ export function Settings({ initialTab = "system-prompt" }: SettingsProps) {
setIsSaving(true)

try {
// Save the system prompt and selected model
const [promptSaveResult, modelSaveResult] = await Promise.all([
// Save the system prompt, selected model, and domain
const [promptSaveResult, modelSaveResult, domainSaveResult] = await Promise.all([
saveSystemPrompt(userId, data.systemPrompt),
saveSelectedModel(data.selectedModel),
saveUserDomain(userId, data.domain || ""),
]);

if (promptSaveResult?.error) {
Expand All @@ -141,6 +173,9 @@ export function Settings({ initialTab = "system-prompt" }: SettingsProps) {
if (modelSaveResult?.error) {
throw new Error(modelSaveResult.error);
}
if (domainSaveResult?.error) {
throw new Error(domainSaveResult.error);
}

console.log("Submitted data:", data)

Expand Down Expand Up @@ -212,22 +247,12 @@ export function Settings({ initialTab = "system-prompt" }: SettingsProps) {
</Card>

<Tabs.Root value={currentTab} onValueChange={setCurrentTab} className="w-full">
<Tabs.List className="grid w-full grid-cols-2 md:grid-cols-4 gap-2">
<Tabs.List className="grid w-full grid-cols-2 md:grid-cols-5 gap-2">
<Tabs.Trigger value="system-prompt" 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">System</Tabs.Trigger>
<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.Trigger value="account" 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">Account</Tabs.Trigger>
</Tabs.List>
<AnimatePresence mode="wait">
<motion.div
Expand Down Expand Up @@ -289,6 +314,92 @@ export function Settings({ initialTab = "system-prompt" }: SettingsProps) {
</CardContent>
</Card>
</Tabs.Content>

<Tabs.Content value="account" className="mt-6">
<Card>
<CardHeader>
<CardTitle>Discord Account Linking</CardTitle>
<CardDescription>Link your Discord account to QCX to access bot features and server sync.</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{clerkUser?.externalAccounts.find(acc => acc.provider === 'discord') ? (
(() => {
const discordAccount = clerkUser.externalAccounts.find(acc => acc.provider === 'discord')!;
return (
<div className="flex items-center gap-4 p-4 border rounded-xl bg-accent/20 border-primary/50">
<div className="h-12 w-12 rounded-full overflow-hidden bg-muted flex items-center justify-center">
{discordAccount.imageUrl ? (
<img src={discordAccount.imageUrl} alt="Discord Avatar" className="h-full w-full object-cover" />
) : (
<span className="text-xl font-bold text-primary">D</span>
)}
</div>
<div className="flex-1">
<p className="font-semibold text-foreground">Connected as {discordAccount.username || 'Discord User'}</p>
<p className="text-xs text-muted-foreground">ID: {discordAccount.providerUserId}</p>
</div>
<Button
type="button"
variant="destructive"
size="sm"
onClick={async () => {
try {
await discordAccount.destroy();
await clerkUser.reload();
toast({
title: "Discord Unlinked",
description: "Your Discord account has been disconnected.",
});
} catch (err: any) {
toast({
title: "Error",
description: err.message || "Failed to disconnect Discord account.",
variant: "destructive",
});
}
}}
>
Disconnect
</Button>
</div>
)
})()
) : (
<div className="flex flex-col items-center justify-center py-6 text-center space-y-4">
<p className="text-sm text-muted-foreground">
You haven&apos;t connected your Discord account yet. Link it now to connect your SaaS profile.
</p>
<Button
type="button"
className="bg-[#5865F2] hover:bg-[#4752C4] text-white flex items-center gap-2 px-6 py-5 text-base font-semibold"
onClick={async () => {
try {
const res = await clerkUser?.createExternalAccount({
strategy: 'oauth_discord',
redirectUrl: window.location.href,
});
if (res?.verification?.externalVerificationRedirectURL) {
window.location.href = res.verification.externalVerificationRedirectURL.href;
}
} catch (err: any) {
toast({
title: "Connection Error",
description: err.message || "Failed to initiate Discord linking.",
variant: "destructive",
});
}
}}
>
<svg className="h-5 w-5 fill-current" viewBox="0 0 127.14 96.36">
<path d="M107.7,8.07A105.15,105.15,0,0,0,77.26,0a77.19,77.19,0,0,0-3.3,6.83A96.67,96.67,0,0,0,52.88,6.83,77.19,77.19,0,0,0,49.58,0,105.15,105.15,0,0,0,19.14,8.07C3,36.79-1.45,64.83.45,92.48a106.4,106.4,0,0,0,32.22,16.22,78,78,0,0,0,6.77-11,68.86,68.86,0,0,1-10.74-5.12c.91-.66,1.8-1.34,2.65-2a75.58,75.58,0,0,0,64,0c.86.69,1.75,1.37,2.65,2a68.86,68.86,0,0,1-10.74,5.12,78,78,0,0,0,6.77,11,106.4,106.4,0,0,0,32.22-16.22C129.21,64.83,124.2,36.79,107.7,8.07ZM42.45,65.69C36.18,65.69,31,60,31,53S36.18,40.36,42.45,40.36,53.83,46,53.83,53,48.72,65.69,42.45,65.69Zm42.24,0C78.41,65.69,73.24,60,73.24,53S78.41,40.36,84.69,40.36,96.07,46,96.07,53,91,65.69,84.69,65.69Z"/>
</svg>
<span>Connect Discord Account</span>
</Button>
</div>
)}
</CardContent>
</Card>
</Tabs.Content>
</motion.div>
</AnimatePresence>
</Tabs.Root>
Expand Down
Loading