feat/granola connector (#1007)

This commit is contained in:
sreedharsreeram 2026-06-03 15:19:48 +00:00
parent f65551fdff
commit 63a90d1f68
6 changed files with 323 additions and 11 deletions

View file

@ -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,
@ -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"
@ -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,
@ -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. */
@ -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">
@ -300,8 +310,12 @@ export function ConnectContent({ selectedProject }: ConnectContentProps) {
const queryClient = useQueryClient()
const autumn = useCustomer()
const isProUser = hasActivePlan(autumn.data?.subscriptions, "api_pro")
// Granola is a Max-tier (and above) connector, like the Gmail connector.
// Lower plans see it but can't connect — the API-key modal stays gated.
const isMaxUser = hasActivePlan(autumn.data?.subscriptions, "api_max")
const [connectingProvider, setConnectingProvider] =
useState<ConnectorProvider | null>(null)
const [granolaModalOpen, setGranolaModalOpen] = useState(false)
const [gdriveSyncScope, setGdriveSyncScope] =
useState<GDriveSyncScope>("scoped")
const [isUpgrading, setIsUpgrading] = useState(false)
@ -592,6 +606,21 @@ export function ConnectContent({ selectedProject }: ConnectContentProps) {
</DropdownMenuContent>
</DropdownMenu>
</div>
) : provider === "granola" ? (
<>
{!isMaxUser && (
<span className="bg-[#0054AD] text-[#FAFAFA] text-[10px] font-bold px-1.5 py-[2px] rounded-[3px] uppercase tracking-wide">
Max
</span>
)}
<Button
onClick={() => setGranolaModalOpen(true)}
disabled={!isMaxUser}
className="bg-[#4BA0FA] text-black hover:bg-[#4BA0FA]/90 text-[14px] font-medium px-3 py-1.5 h-8"
>
Connect
</Button>
</>
) : (
<Button
onClick={() =>
@ -753,6 +782,26 @@ export function ConnectContent({ selectedProject }: ConnectContentProps) {
</span>
</div>
</DropdownMenuItem>
<DropdownMenuItem
disabled={!isMaxUser}
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 data-disabled:opacity-40 data-disabled:cursor-not-allowed data-disabled:hover:bg-transparent"
>
<Granola className="size-5 mt-0.5 shrink-0" />
<div className="flex flex-col gap-0.5 min-w-0">
<span className="flex items-center gap-1.5 text-[14px] font-medium text-[#FAFAFA] leading-tight">
Granola
{!isMaxUser && (
<span className="bg-[#0054AD] text-[#FAFAFA] text-[9px] font-bold px-1 py-px rounded-[3px] uppercase tracking-wide">
Max
</span>
)}
</span>
<span className="text-[11px] text-[#737373] leading-tight">
Meeting notes & transcripts
</span>
</div>
</DropdownMenuItem>
</div>
</div>
</DropdownMenuContent>
@ -879,6 +928,12 @@ export function ConnectContent({ selectedProject }: ConnectContentProps) {
}}
isDeleting={deleteConnectionMutation.isPending}
/>
<GranolaConnectModal
open={granolaModalOpen}
onOpenChange={setGranolaModalOpen}
containerTags={[selectedProject]}
/>
</div>
)
}

View file

@ -0,0 +1,221 @@
"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 },
},
})
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>
)
}

View file

@ -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 {
@ -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>
@ -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
@ -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">

View file

@ -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"
@ -53,3 +61,4 @@ export type ImportProvider =
| "github"
| "web-crawler"
| "s3"
| "granola"

View file

@ -84,13 +84,15 @@ export const apiSchema = createSchema({
redirectUrl: z.string().optional(),
}),
output: z.object({
authLink: z.string(),
expiresIn: z.string(),
// authLink/expiresIn are present for OAuth providers (Drive/Notion/OneDrive)
// but absent for credential-based ones like Granola where there's no redirect.
authLink: z.string().optional(),
expiresIn: z.string().optional(),
id: z.string(),
redirectsTo: z.string().optional(),
}),
params: z.object({
provider: z.enum(["google-drive", "notion", "onedrive"]),
provider: z.enum(["google-drive", "notion", "onedrive", "granola"]),
}),
},
@ -158,6 +160,7 @@ export const apiSchema = createSchema({
"github",
"web-crawler",
"s3",
"granola",
]),
}),
},

File diff suppressed because one or more lines are too long