mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-08-28 05:25:33 +00:00
feat/granola connector (#1007)
This commit is contained in:
parent
f65551fdff
commit
63a90d1f68
6 changed files with 323 additions and 11 deletions
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
221
apps/web/components/granola-connect-modal.tsx
Normal file
221
apps/web/components/granola-connect-modal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Add table
Reference in a new issue