From 736139276b37f78b104b8fd09a72bf506760e9b2 Mon Sep 17 00:00:00 2001 From: Sreeram Sreedhar Date: Mon, 25 May 2026 22:44:36 -0400 Subject: [PATCH 1/9] initial commit --- apps/web/components/settings/sync-utils.ts | 2 ++ packages/lib/api.ts | 3 ++- packages/ui/assets/icons.tsx | 11 +++++++++++ 3 files changed, 15 insertions(+), 1 deletion(-) diff --git a/apps/web/components/settings/sync-utils.ts b/apps/web/components/settings/sync-utils.ts index 25a683ba8..f1285e5bb 100644 --- a/apps/web/components/settings/sync-utils.ts +++ b/apps/web/components/settings/sync-utils.ts @@ -42,6 +42,7 @@ export const PROVIDER_DISPLAY_NAMES: Record = { github: "GitHub", "web-crawler": "Web Crawler", s3: "S3", + granola: "Granola", } /** Provider type union matching the backend import endpoint */ @@ -53,3 +54,4 @@ export type ImportProvider = | "github" | "web-crawler" | "s3" + | "granola" diff --git a/packages/lib/api.ts b/packages/lib/api.ts index 4ce787f23..8545c3a4c 100644 --- a/packages/lib/api.ts +++ b/packages/lib/api.ts @@ -90,7 +90,7 @@ export const apiSchema = createSchema({ redirectsTo: z.string().optional(), }), params: z.object({ - provider: z.enum(["google-drive", "notion", "onedrive"]), + provider: z.enum(["google-drive", "notion", "onedrive", "granola"]), }), }, @@ -158,6 +158,7 @@ export const apiSchema = createSchema({ "github", "web-crawler", "s3", + "granola", ]), }), }, diff --git a/packages/ui/assets/icons.tsx b/packages/ui/assets/icons.tsx index 9c30d2e3e..597d6a31d 100644 --- a/packages/ui/assets/icons.tsx +++ b/packages/ui/assets/icons.tsx @@ -362,3 +362,14 @@ export const ClaudeDesktopIcon = ({ className }: { className?: string }) => { ) } + +export const Granola = ({ className }: { className?: string }) => ( + + Granola + + +) From 1f82dbeb551a7f2ae37fe45df0723f19f2e36742 Mon Sep 17 00:00:00 2001 From: Sreeram Sreedhar Date: Mon, 25 May 2026 22:56:22 -0400 Subject: [PATCH 2/9] =?UTF-8?q?GranolaConnectModal=20=E2=80=94=20API-key?= =?UTF-8?q?=20entry=20dialog?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/components/granola-connect-modal.tsx | 222 ++++++++++++++++++ 1 file changed, 222 insertions(+) create mode 100644 apps/web/components/granola-connect-modal.tsx diff --git a/apps/web/components/granola-connect-modal.tsx b/apps/web/components/granola-connect-modal.tsx new file mode 100644 index 000000000..866e09906 --- /dev/null +++ b/apps/web/components/granola-connect-modal.tsx @@ -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 ( +
+ +
+ ) +} + +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(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 }, + 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 ( + + + Connect Granola + +
+ +
+

+ Connect Granola +

+

+ Paste your API key to sync meeting notes. +

+
+ + + +
+ +
+ + { + 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]", + )} + /> +

+ Create one in Granola → Settings → Connectors → API keys. + Requires a Business or Enterprise plan. +

+ {errorMessage && ( +

+ {errorMessage} +

+ )} +
+ +
+ + +
+
+
+ ) +} From 47fa3b9f49d96df7d2640b14c1cab1f2776af701 Mon Sep 17 00:00:00 2001 From: Sreeram Sreedhar Date: Mon, 25 May 2026 22:59:28 -0400 Subject: [PATCH 3/9] add-document dropdown --- .../components/add-document/connections.tsx | 32 +++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/apps/web/components/add-document/connections.tsx b/apps/web/components/add-document/connections.tsx index 7aa0250d5..610217b5f 100644 --- a/apps/web/components/add-document/connections.tsx +++ b/apps/web/components/add-document/connections.tsx @@ -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,6 +31,7 @@ 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" @@ -48,7 +49,7 @@ const GDRIVE_SCOPE_LABELS: Record = { type Connection = z.infer -type ConnectorProvider = "google-drive" | "notion" | "onedrive" +type ConnectorProvider = "google-drive" | "notion" | "onedrive" | "granola" const CONNECTORS: Record< ConnectorProvider, @@ -77,6 +78,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. */ @@ -302,6 +309,7 @@ export function ConnectContent({ selectedProject }: ConnectContentProps) { const isProUser = hasActivePlan(autumn.data?.subscriptions, "api_pro") const [connectingProvider, setConnectingProvider] = useState(null) + const [granolaModalOpen, setGranolaModalOpen] = useState(false) const [gdriveSyncScope, setGdriveSyncScope] = useState("scoped") const [isUpgrading, setIsUpgrading] = useState(false) @@ -753,6 +761,20 @@ export function ConnectContent({ selectedProject }: ConnectContentProps) { + 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 + + + Meeting notes & transcripts + +
+
@@ -879,6 +901,12 @@ export function ConnectContent({ selectedProject }: ConnectContentProps) { }} isDeleting={deleteConnectionMutation.isPending} /> + + ) } From 9d8497aad70b9e36e8dbcaa59654b5f65bc7e7b0 Mon Sep 17 00:00:00 2001 From: Sreeram Sreedhar Date: Mon, 25 May 2026 23:02:20 -0400 Subject: [PATCH 4/9] render Granola connections in settings page --- apps/web/components/settings/connections-mcp.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/apps/web/components/settings/connections-mcp.tsx b/apps/web/components/settings/connections-mcp.tsx index 26412728f..bf608b1e1 100644 --- a/apps/web/components/settings/connections-mcp.tsx +++ b/apps/web/components/settings/connections-mcp.tsx @@ -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 { @@ -68,6 +68,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 From 98f0c2b814e08a2787198516d9c1692f9410f73e Mon Sep 17 00:00:00 2001 From: Sreeram Sreedhar Date: Mon, 25 May 2026 23:27:00 -0400 Subject: [PATCH 5/9] schema fix --- packages/lib/api.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/lib/api.ts b/packages/lib/api.ts index 8545c3a4c..7d33b1307 100644 --- a/packages/lib/api.ts +++ b/packages/lib/api.ts @@ -84,8 +84,10 @@ 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(), }), From 3bef2cd47c70573145682eb02e07d8d63176446a Mon Sep 17 00:00:00 2001 From: Sreeram Sreedhar Date: Mon, 25 May 2026 23:33:00 -0400 Subject: [PATCH 6/9] fix --- apps/web/components/add-document/connections.tsx | 7 +++++-- apps/web/components/settings/connections-mcp.tsx | 7 +++++-- apps/web/components/settings/sync-utils.ts | 9 ++++++++- 3 files changed, 18 insertions(+), 5 deletions(-) diff --git a/apps/web/components/add-document/connections.tsx b/apps/web/components/add-document/connections.tsx index 610217b5f..e81a36b3a 100644 --- a/apps/web/components/add-document/connections.tsx +++ b/apps/web/components/add-document/connections.tsx @@ -37,7 +37,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 GDriveSyncScope = "scoped" | "full" @@ -169,7 +172,7 @@ function ConnectionRow({ "truncate text-[14px] text-[#737373]", )} > - {connection.email || "Unknown"} + {getConnectionSubtitle(connection)}
diff --git a/apps/web/components/settings/connections-mcp.tsx b/apps/web/components/settings/connections-mcp.tsx index bf608b1e1..08e79fa32 100644 --- a/apps/web/components/settings/connections-mcp.tsx +++ b/apps/web/components/settings/connections-mcp.tsx @@ -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 @@ -237,7 +240,7 @@ function ConnectionRow({ "font-medium text-[16px] tracking-[-0.16px] text-[#737373]", )} > - {connection.email || "Unknown"} + {getConnectionSubtitle(connection)}
diff --git a/apps/web/components/settings/sync-utils.ts b/apps/web/components/settings/sync-utils.ts index f1285e5bb..7028b545d 100644 --- a/apps/web/components/settings/sync-utils.ts +++ b/apps/web/components/settings/sync-utils.ts @@ -45,7 +45,14 @@ export const PROVIDER_DISPLAY_NAMES: Record = { granola: "Granola", } -/** Provider type union matching the backend import endpoint */ +export function getConnectionSubtitle(conn: { + provider: string + email?: string | null +}): string { + if (conn.provider === "granola") return "Granola workspace" + return conn.email || "Unknown" +} + export type ImportProvider = | "google-drive" | "notion" From 2759849913c4bfbc6cb7199441ec6373354b099f Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Tue, 26 May 2026 03:37:05 +0000 Subject: [PATCH 7/9] fix: biome formatting Co-Authored-By: Claude Opus 4.5 --- apps/web/components/granola-connect-modal.tsx | 4 ++-- packages/ui/assets/icons.tsx | 6 +++++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/apps/web/components/granola-connect-modal.tsx b/apps/web/components/granola-connect-modal.tsx index 866e09906..0a7d89ef5 100644 --- a/apps/web/components/granola-connect-modal.tsx +++ b/apps/web/components/granola-connect-modal.tsx @@ -174,8 +174,8 @@ export function GranolaConnectModal({ "mt-2 text-[11px] leading-snug text-[#737373]", )} > - Create one in Granola → Settings → Connectors → API keys. - Requires a Business or Enterprise plan. + Create one in Granola → Settings → Connectors → API keys. Requires a + Business or Enterprise plan.

{errorMessage && (

( xmlns="http://www.w3.org/2000/svg" > Granola - + ) From 5a3f499a6c57884797da74486bc8551207152418 Mon Sep 17 00:00:00 2001 From: Sreeram Sreedhar Date: Wed, 3 Jun 2026 01:22:57 -0400 Subject: [PATCH 8/9] max plan --- .../components/add-document/connections.tsx | 32 +++++++++++++++++-- 1 file changed, 29 insertions(+), 3 deletions(-) diff --git a/apps/web/components/add-document/connections.tsx b/apps/web/components/add-document/connections.tsx index e81a36b3a..f77197d6d 100644 --- a/apps/web/components/add-document/connections.tsx +++ b/apps/web/components/add-document/connections.tsx @@ -310,6 +310,9 @@ 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(null) const [granolaModalOpen, setGranolaModalOpen] = useState(false) @@ -603,6 +606,21 @@ export function ConnectContent({ selectedProject }: ConnectContentProps) {

+ ) : provider === "granola" ? ( + <> + {!isMaxUser && ( + + Max + + )} + + ) : (