Skip to content
Merged
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
63 changes: 59 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 @@ -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)
Expand Down Expand Up @@ -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={() =>
Expand Down Expand Up @@ -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>
Expand Down Expand Up @@ -879,6 +928,12 @@ export function ConnectContent({ selectedProject }: ConnectContentProps) {
}}
isDeleting={deleteConnectionMutation.isPending}
/>

<GranolaConnectModal
open={granolaModalOpen}
onOpenChange={setGranolaModalOpen}
containerTags={[selectedProject]}
/>
</div>
)
}
221 changes: 221 additions & 0 deletions apps/web/components/granola-connect-modal.tsx
Original file line number Diff line number Diff line change
@@ -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>
)
}
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
Loading
Loading