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
39 changes: 35 additions & 4 deletions apps/web/components/add-document/connections.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { $fetch } from "@lib/api"
import { hasActivePlan } from "@lib/queries"
import type { ConnectionResponseSchema } from "@repo/validation/api"
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { GoogleDrive, Notion, OneDrive } from "@ui/assets/icons"
import { GoogleDrive, Granola, Notion, OneDrive } from "@ui/assets/icons"
import { useCustomer } from "autumn-js/react"
import {
Check,
Expand All @@ -31,12 +31,16 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from "@ui/components/dropdown-menu"
import { GranolaConnectModal } from "@/components/granola-connect-modal"
import { RemoveConnectionDialog } from "@/components/remove-connection-dialog"
import { SyncStatusBadge } from "@/components/settings/sync-status-badge"
import { SyncHistoryPanel } from "@/components/settings/sync-history-panel"
import { useConnectionHealth } from "@/hooks/use-connection-health"
import { useTriggerSync } from "@/hooks/use-trigger-sync"
import { formatRelativeTime } from "@/components/settings/sync-utils"
import {
formatRelativeTime,
getConnectionSubtitle,
} from "@/components/settings/sync-utils"
import type { ImportProvider } from "@/components/settings/sync-utils"

type GDriveSyncScope = "scoped" | "full"
Expand All @@ -48,7 +52,7 @@ const GDRIVE_SCOPE_LABELS: Record<GDriveSyncScope, string> = {

type Connection = z.infer<typeof ConnectionResponseSchema>

type ConnectorProvider = "google-drive" | "notion" | "onedrive"
type ConnectorProvider = "google-drive" | "notion" | "onedrive" | "granola"

const CONNECTORS: Record<
ConnectorProvider,
Expand Down Expand Up @@ -77,6 +81,12 @@ const CONNECTORS: Record<
documentLabel: "documents",
icon: OneDrive,
},
granola: {
title: "Granola",
description: "Sync AI meeting notes and transcripts",
documentLabel: "notes",
icon: Granola,
},
} as const

/** Extract typed metadata from a connection, with runtime validation. */
Expand Down Expand Up @@ -162,7 +172,7 @@ function ConnectionRow({
"truncate text-[14px] text-[#737373]",
)}
>
{connection.email || "Unknown"}
{getConnectionSubtitle(connection)}
</span>
</div>
<div className="flex shrink-0 items-center gap-0.5">
Expand Down Expand Up @@ -302,6 +312,7 @@ export function ConnectContent({ selectedProject }: ConnectContentProps) {
const isProUser = hasActivePlan(autumn.data?.subscriptions, "api_pro")
const [connectingProvider, setConnectingProvider] =
useState<ConnectorProvider | null>(null)
const [granolaModalOpen, setGranolaModalOpen] = useState(false)
const [gdriveSyncScope, setGdriveSyncScope] =
useState<GDriveSyncScope>("scoped")
const [isUpgrading, setIsUpgrading] = useState(false)
Expand Down Expand Up @@ -753,6 +764,20 @@ export function ConnectContent({ selectedProject }: ConnectContentProps) {
</span>
</div>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setGranolaModalOpen(true)}
className="flex items-start gap-2.5 px-3 py-2.5 rounded-md cursor-pointer text-white opacity-60 hover:opacity-100 hover:bg-[#293952]/40 focus:bg-[#293952]/40 focus:opacity-100"
>
<Granola className="size-5 mt-0.5 shrink-0" />
<div className="flex flex-col gap-0.5 min-w-0">
<span className="text-[14px] font-medium text-[#FAFAFA] leading-tight">
Granola
</span>
<span className="text-[11px] text-[#737373] leading-tight">
Meeting notes & transcripts
</span>
</div>
</DropdownMenuItem>
</div>
</div>
</DropdownMenuContent>
Expand Down Expand Up @@ -879,6 +904,12 @@ export function ConnectContent({ selectedProject }: ConnectContentProps) {
}}
isDeleting={deleteConnectionMutation.isPending}
/>

<GranolaConnectModal
open={granolaModalOpen}
onOpenChange={setGranolaModalOpen}
containerTags={[selectedProject]}
/>
</div>
)
}
222 changes: 222 additions & 0 deletions apps/web/components/granola-connect-modal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
"use client"

import * as DialogPrimitive from "@radix-ui/react-dialog"
import { useMutation, useQueryClient } from "@tanstack/react-query"
import { Loader2, X } from "lucide-react"
import { useEffect, useState } from "react"
import { toast } from "sonner"
import { $fetch } from "@lib/api"
import { cn } from "@lib/utils"
import { Dialog, DialogContent, DialogTitle } from "@ui/components/dialog"
import { Granola } from "@ui/assets/icons"
import { dmSans125ClassName } from "@/lib/fonts"
import { INSET } from "./integrations/install-steps"

function GranolaIconBox() {
return (
<div
className={cn(
"flex size-10 shrink-0 items-center justify-center rounded-[10px] bg-[#080B0F]",
"shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.6)]",
)}
>
<Granola className="size-6" />
</div>
)
}

export function GranolaConnectModal({
open,
onOpenChange,
containerTags,
onSuccess,
}: {
open: boolean
onOpenChange: (open: boolean) => void
containerTags?: string[]
onSuccess?: () => void
}) {
const queryClient = useQueryClient()
const [apiKey, setApiKey] = useState("")
const [errorMessage, setErrorMessage] = useState<string | null>(null)

// Reset form whenever the modal opens.
useEffect(() => {
if (open) {
setApiKey("")
setErrorMessage(null)
}
}, [open])

const connectMutation = useMutation({
mutationFn: async (key: string) => {
const response = await $fetch("@post/connections/:provider", {
params: { provider: "granola" },
body: {
containerTags,
metadata: { apiKey: key },
Comment thread
sreedharsreeram marked this conversation as resolved.
redirectUrl: window.location.href,
},
})
if (response.error) {
const msg =
(response.error as { message?: string })?.message ||
"Failed to connect"
throw new Error(msg)
}
return response.data
},
onSuccess: () => {
toast.success("Granola connected")
queryClient.invalidateQueries({ queryKey: ["connections"] })
onSuccess?.()
onOpenChange(false)
},
onError: (error) => {
setErrorMessage(
error instanceof Error ? error.message : "Failed to connect",
)
},
})

const trimmedKey = apiKey.trim()
const canConnect = trimmedKey.length > 0 && !connectMutation.isPending

const handleConnect = () => {
setErrorMessage(null)
connectMutation.mutate(trimmedKey)
}

return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
showCloseButton={false}
style={{
boxShadow:
"0 2.842px 14.211px 0 rgba(0,0,0,0.25), 0.711px 0.711px 0.711px 0 rgba(255,255,255,0.10) inset",
}}
className={cn(
dmSans125ClassName(),
"flex max-h-[88dvh] flex-col gap-3 overflow-hidden border border-white/[0.12] bg-[#1B1F24] p-0 px-3 pt-3 pb-4 rounded-2xl md:px-4 sm:max-w-[480px] sm:rounded-[22px]",
)}
>
<DialogTitle className="sr-only">Connect Granola</DialogTitle>

<div className="flex shrink-0 items-center gap-3">
<GranolaIconBox />
<div className="min-w-0 flex-1">
<p
className={cn(
dmSans125ClassName(),
"truncate text-[16px] font-semibold leading-tight text-[#FAFAFA]",
)}
>
Connect Granola
</p>
<p
className={cn(
dmSans125ClassName(),
"mt-0.5 truncate text-[12px] text-[#A1A1AA]",
)}
>
Paste your API key to sync meeting notes.
</p>
</div>
<DialogPrimitive.Close
type="button"
aria-label="Close"
className={cn(
"flex size-7 items-center justify-center rounded-full bg-[#0D121A] transition-opacity hover:opacity-80 focus:outline-none",
INSET,
)}
>
<X className="size-4 text-[#737373]" />
</DialogPrimitive.Close>
</div>

<div
className={cn(
"min-w-0 rounded-[14px] bg-[#14161A] p-4 sm:p-5",
INSET,
)}
>
<label
htmlFor="granola-api-key"
className={cn(
dmSans125ClassName(),
"mb-2 block text-[12px] font-medium text-[#A1A1AA]",
)}
>
Granola API Key
</label>
<input
id="granola-api-key"
type="password"
autoComplete="off"
spellCheck={false}
value={apiKey}
onChange={(e) => {
setApiKey(e.target.value)
if (errorMessage) setErrorMessage(null)
}}
onKeyDown={(e) => {
if (e.key === "Enter" && canConnect) handleConnect()
}}
placeholder="grn_..."
className={cn(
dmSans125ClassName(),
"w-full rounded-[10px] bg-[#0D121A] px-3 py-2.5 text-[13px] text-[#FAFAFA] placeholder:text-[#52525B] outline-none border border-white/[0.06] focus:border-white/[0.16]",
)}
/>
<p
className={cn(
dmSans125ClassName(),
"mt-2 text-[11px] leading-snug text-[#737373]",
)}
>
Create one in Granola → Settings → Connectors → API keys. Requires a
Business or Enterprise plan.
</p>
{errorMessage && (
<p
className={cn(
dmSans125ClassName(),
"mt-2 text-[12px] leading-snug text-[#F87171]",
)}
>
{errorMessage}
</p>
)}
</div>

<div className="flex shrink-0 items-center justify-end gap-2">
<button
type="button"
onClick={() => onOpenChange(false)}
className={cn(
dmSans125ClassName(),
"flex h-9 items-center gap-1.5 rounded-full bg-[#0D121A] px-5 text-[13px] font-medium text-[#A1A1AA] transition-opacity hover:opacity-80",
INSET,
)}
>
Cancel
</button>
<button
type="button"
onClick={handleConnect}
disabled={!canConnect}
className={cn(
dmSans125ClassName(),
"flex h-9 items-center gap-1.5 rounded-full bg-[#4BA0FA] px-5 text-[13px] font-semibold text-[#00171A] transition-opacity hover:opacity-80 disabled:opacity-50 disabled:cursor-not-allowed",
)}
>
{connectMutation.isPending && (
<Loader2 className="size-3.5 animate-spin" />
)}
Connect
</button>
</div>
</DialogContent>
</Dialog>
)
}
15 changes: 12 additions & 3 deletions apps/web/components/settings/connections-mcp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { dmSans125ClassName } from "@/lib/fonts"
import { cn } from "@lib/utils"
import { $fetch } from "@lib/api"
import { hasActivePlan } from "@lib/queries"
import { GoogleDrive, Notion, OneDrive } from "@ui/assets/icons"
import { GoogleDrive, Granola, Notion, OneDrive } from "@ui/assets/icons"
import { useCustomer } from "autumn-js/react"
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import {
Expand Down Expand Up @@ -33,7 +33,10 @@ import { SyncStatusBadge } from "@/components/settings/sync-status-badge"
import { SyncHistoryPanel } from "@/components/settings/sync-history-panel"
import { useConnectionHealth } from "@/hooks/use-connection-health"
import { useTriggerSync } from "@/hooks/use-trigger-sync"
import { formatRelativeTime } from "@/components/settings/sync-utils"
import {
formatRelativeTime,
getConnectionSubtitle,
} from "@/components/settings/sync-utils"
import type { ImportProvider } from "@/components/settings/sync-utils"

type Connection = z.infer<typeof ConnectionResponseSchema>
Expand Down Expand Up @@ -68,6 +71,12 @@ const CONNECTORS = {
icon: OneDrive,
documentLabel: "documents",
},
granola: {
title: "Granola",
description: "Sync AI meeting notes and transcripts",
icon: Granola,
documentLabel: "notes",
},
} as const

type ConnectorProvider = keyof typeof CONNECTORS
Expand Down Expand Up @@ -231,7 +240,7 @@ function ConnectionRow({
"font-medium text-[16px] tracking-[-0.16px] text-[#737373]",
)}
>
{connection.email || "Unknown"}
{getConnectionSubtitle(connection)}
</span>
</div>
<div className="flex items-center gap-0.5">
Expand Down
11 changes: 10 additions & 1 deletion apps/web/components/settings/sync-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,17 @@ export const PROVIDER_DISPLAY_NAMES: Record<string, string> = {
github: "GitHub",
"web-crawler": "Web Crawler",
s3: "S3",
granola: "Granola",
}

export function getConnectionSubtitle(conn: {
provider: string
email?: string | null
}): string {
if (conn.provider === "granola") return "Granola workspace"
return conn.email || "Unknown"
}

/** Provider type union matching the backend import endpoint */
export type ImportProvider =
| "google-drive"
| "notion"
Expand All @@ -53,3 +61,4 @@ export type ImportProvider =
| "github"
| "web-crawler"
| "s3"
| "granola"
Loading
Loading