mirror of
https://github.com/warmbly/warmbly.git
synced 2026-10-30 08:15:41 +00:00
feat: add integration oauth connections
Adds OAuth-backed integration connection management across the API, repository, event dispatch, migrations, docs, and dashboard UI. Includes realtime invalidation and small dashboard type compatibility fixes needed for the web typecheck gate.
This commit is contained in:
@@ -158,8 +158,13 @@ function AWSCredsForm({
|
||||
secret_access_key: "",
|
||||
});
|
||||
const mut = useMutation({
|
||||
mutationFn: () =>
|
||||
initial ? updateAWSCredentials(initial.id, form) : createAWSCredentials(form),
|
||||
mutationFn: async () => {
|
||||
if (initial) {
|
||||
await updateAWSCredentials(initial.id, form);
|
||||
return { id: initial.id };
|
||||
}
|
||||
return createAWSCredentials(form);
|
||||
},
|
||||
onSuccess: onSaved,
|
||||
});
|
||||
|
||||
|
||||
@@ -255,7 +255,7 @@ export default function AuditPage() {
|
||||
);
|
||||
}
|
||||
|
||||
function Th({ children, className }: { children: React.ReactNode; className?: string }) {
|
||||
function Th({ children, className }: { children?: React.ReactNode; className?: string }) {
|
||||
return (
|
||||
<th
|
||||
className={`px-3 py-2 text-[10px] font-medium text-slate-400 uppercase tracking-[0.14em] ${className ?? ""}`}
|
||||
|
||||
@@ -1,24 +1,49 @@
|
||||
// Drawer that handles per-provider connect inputs. Each provider has a
|
||||
// slightly different set of required fields:
|
||||
// - webhook providers (Calendly, Cal.com): no fields, just a label
|
||||
// - oauth providers (HubSpot, Salesforce, Pipedrive, Google Sheets,
|
||||
// Slack): launch OAuth (we accept a pasted token until OAuth lands)
|
||||
// - api-key providers (Close, Zapier, Make, n8n): paste a token
|
||||
// - webhook-url providers (Discord): paste the channel webhook URL
|
||||
// Multi-step connect drawer. The connect path depends on the provider's
|
||||
// auth method:
|
||||
// - oauth (HubSpot, Slack, Google Sheets, Pipedrive, Salesforce): one-click
|
||||
// "Connect with X" → provider popup → encrypted tokens stored server-side.
|
||||
// No credentials are ever pasted. Providers without server credentials
|
||||
// render as "coming soon".
|
||||
// - api_key (Close, Zapier, Make, n8n): paste a provider token (Close) or a
|
||||
// scoped Warmbly API key (Zapier/Make/n8n).
|
||||
// - webhook (Discord): paste a channel webhook URL.
|
||||
// - inbound webhook (Calendly, Cal.com): we mint an inbound URL on connect.
|
||||
//
|
||||
// Step 1 is always an honest overview — what you get, which scopes are
|
||||
// requested. Step 2 is the connect action. Step 3 confirms + points to the
|
||||
// detail drawer where event automations are configured.
|
||||
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { XIcon } from "lucide-react";
|
||||
import {
|
||||
ArrowRightIcon,
|
||||
CheckCircle2Icon,
|
||||
ExternalLinkIcon,
|
||||
KeyRoundIcon,
|
||||
Loader2Icon,
|
||||
LockIcon,
|
||||
ShieldCheckIcon,
|
||||
XIcon,
|
||||
ZapIcon,
|
||||
} from "lucide-react";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import { Label, TextInput } from "@/components/ui/field";
|
||||
import useConnectIntegration from "@/lib/api/hooks/app/integrations/useConnectIntegration";
|
||||
import {
|
||||
useFinishIntegrationOAuth,
|
||||
useStartIntegrationOAuth,
|
||||
} from "@/lib/api/hooks/app/integrations/useIntegrationOAuth";
|
||||
import { openOAuthPopup } from "@/lib/integrations/oauthPopup";
|
||||
import type {
|
||||
IntegrationCatalogEntry,
|
||||
IntegrationConnection,
|
||||
} from "@/lib/api/models/app/integrations/Integration";
|
||||
import useConnectIntegration from "@/lib/api/hooks/app/integrations/useConnectIntegration";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import ProviderGlyph from "./ProviderGlyph";
|
||||
|
||||
interface FieldDef {
|
||||
key: string;
|
||||
label: string;
|
||||
@@ -28,62 +53,62 @@ interface FieldDef {
|
||||
required?: boolean;
|
||||
}
|
||||
|
||||
// Credential fields for non-OAuth providers only. OAuth providers never paste.
|
||||
const FIELDS_BY_PROVIDER: Record<string, FieldDef[]> = {
|
||||
calendly: [],
|
||||
cal_com: [],
|
||||
|
||||
hubspot: [
|
||||
{ key: "workspace", label: "Workspace name", placeholder: "Acme" },
|
||||
{ key: "access_token", label: "OAuth access token", type: "password", required: true,
|
||||
helper: "Paste a token with crm.objects.contacts read/write scope." },
|
||||
],
|
||||
salesforce: [
|
||||
{ key: "workspace", label: "Org name", placeholder: "Acme Salesforce" },
|
||||
{ key: "access_token", label: "OAuth access token", type: "password", required: true,
|
||||
helper: "Paste a Salesforce session ID or OAuth bearer." },
|
||||
],
|
||||
pipedrive: [
|
||||
{ key: "workspace", label: "Company name", placeholder: "Acme" },
|
||||
{ key: "api_token", label: "API token", type: "password", required: true,
|
||||
helper: "Settings → Personal → API in your Pipedrive account." },
|
||||
],
|
||||
close: [
|
||||
{ key: "workspace", label: "Organization", placeholder: "Acme" },
|
||||
{ key: "api_token", label: "API key", type: "password", required: true,
|
||||
helper: "Settings → Developer in Close." },
|
||||
{
|
||||
key: "api_token",
|
||||
label: "Close API key",
|
||||
type: "password",
|
||||
required: true,
|
||||
helper: "Settings → Developer → API Keys in Close.",
|
||||
},
|
||||
],
|
||||
|
||||
zapier: [
|
||||
{ key: "api_token", label: "Warmbly API token", type: "password", required: true,
|
||||
helper: "Generate this in API Keys, then paste it into Zapier when prompted." },
|
||||
{
|
||||
key: "api_token",
|
||||
label: "Warmbly API key",
|
||||
type: "password",
|
||||
required: true,
|
||||
helper: "Create a scoped key under Settings → API keys, then paste it into Zapier.",
|
||||
},
|
||||
],
|
||||
make: [
|
||||
{ key: "api_token", label: "Warmbly API token", type: "password", required: true,
|
||||
helper: "Generate this in API Keys, then paste it into Make when prompted." },
|
||||
{
|
||||
key: "api_token",
|
||||
label: "Warmbly API key",
|
||||
type: "password",
|
||||
required: true,
|
||||
helper: "Create a scoped key under Settings → API keys, then paste it into Make.",
|
||||
},
|
||||
],
|
||||
n8n: [
|
||||
{ key: "api_token", label: "Warmbly API token", type: "password", required: true,
|
||||
helper: "Generate this in API Keys, then paste it into n8n when prompted." },
|
||||
],
|
||||
|
||||
slack: [
|
||||
{ key: "workspace", label: "Workspace name", placeholder: "Acme Slack" },
|
||||
{ key: "channel", label: "Channel", placeholder: "#sales" },
|
||||
{ key: "webhook_url", label: "Incoming-webhook URL", type: "password", required: true,
|
||||
helper: "Create an incoming webhook in your Slack admin and paste it here." },
|
||||
{
|
||||
key: "api_token",
|
||||
label: "Warmbly API key",
|
||||
type: "password",
|
||||
required: true,
|
||||
helper: "Create a scoped key under Settings → API keys, then paste it into n8n.",
|
||||
},
|
||||
],
|
||||
discord: [
|
||||
{ key: "server", label: "Server name", placeholder: "Acme" },
|
||||
{ key: "webhook_url", label: "Channel webhook URL", type: "password", required: true,
|
||||
helper: "Edit Channel → Integrations → Webhooks → New Webhook → Copy URL." },
|
||||
{
|
||||
key: "webhook_url",
|
||||
label: "Channel webhook URL",
|
||||
type: "password",
|
||||
required: true,
|
||||
helper: "Edit Channel → Integrations → Webhooks → New Webhook → Copy URL.",
|
||||
},
|
||||
],
|
||||
|
||||
google_sheets: [
|
||||
{ key: "sheet_id", label: "Sheet ID", placeholder: "1AbC...XyZ", required: true,
|
||||
helper: "The long ID in the sheet's URL between /d/ and /edit." },
|
||||
{ key: "sheet_title", label: "Display label", placeholder: "Q2 outbound list" },
|
||||
{ key: "access_token", label: "OAuth access token", type: "password",
|
||||
helper: "Paste a token with Sheets scope. OAuth wiring lands in onboarding." },
|
||||
{
|
||||
key: "sheet_id",
|
||||
label: "Sheet ID",
|
||||
placeholder: "1AbC…XyZ",
|
||||
helper: "The long ID in the sheet URL between /d/ and /edit. Optional — set per automation later.",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -98,24 +123,46 @@ export default function ConnectDrawer({
|
||||
}) {
|
||||
const [label, setLabel] = React.useState("");
|
||||
const [config, setConfig] = React.useState<Record<string, string>>({});
|
||||
const connect = useConnectIntegration();
|
||||
const [step, setStep] = React.useState<"overview" | "credentials">("overview");
|
||||
const [busy, setBusy] = React.useState(false);
|
||||
|
||||
const connect = useConnectIntegration();
|
||||
const startOAuth = useStartIntegrationOAuth();
|
||||
const finishOAuth = useFinishIntegrationOAuth();
|
||||
|
||||
const isOAuth = entry.auth_method === "oauth";
|
||||
const isInbound = entry.provider === "calendly" || entry.provider === "cal_com";
|
||||
const fields = FIELDS_BY_PROVIDER[entry.provider] ?? [];
|
||||
|
||||
function update(key: string, value: string) {
|
||||
setConfig((c) => ({ ...c, [key]: value }));
|
||||
}
|
||||
|
||||
async function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
async function runOAuth() {
|
||||
setBusy(true);
|
||||
try {
|
||||
const { url } = await startOAuth.mutateAsync({ provider: entry.provider, label: label.trim() });
|
||||
const { code, state } = await openOAuthPopup(url);
|
||||
const conn = await finishOAuth.mutateAsync({ code, state });
|
||||
toast.success(`Connected to ${entry.name}`);
|
||||
onConnected(conn);
|
||||
onClose();
|
||||
} catch (err: unknown) {
|
||||
toast.error(errMessage(err) ?? "Connection failed");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function submitCredentials(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
for (const f of fields) {
|
||||
if (f.required && !config[f.key]?.trim()) {
|
||||
toast.error(`${f.label} is required`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setBusy(true);
|
||||
try {
|
||||
const conn = await connect.mutateAsync({
|
||||
provider: entry.provider,
|
||||
@@ -126,11 +173,162 @@ export default function ConnectDrawer({
|
||||
onConnected(conn);
|
||||
onClose();
|
||||
} catch (err: unknown) {
|
||||
const e = err as { response?: { data?: { error?: string } }; message?: string };
|
||||
toast.error(e.response?.data?.error ?? e.message ?? "Connect failed");
|
||||
toast.error(errMessage(err) ?? "Connect failed");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
const notConfigured = isOAuth && !entry.configured;
|
||||
|
||||
return (
|
||||
<Drawer title="Connect" name={entry.name} provider={entry.provider} onClose={onClose}>
|
||||
{step === "overview" && (
|
||||
<div className="flex-1 overflow-auto flex flex-col">
|
||||
<div className="px-5 py-5 space-y-5">
|
||||
<p className="text-[12.5px] text-slate-600 leading-relaxed">{entry.tagline}</p>
|
||||
|
||||
{entry.highlights && entry.highlights.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<SectionLabel>What you get</SectionLabel>
|
||||
<ul className="space-y-1.5">
|
||||
{entry.highlights.map((h) => (
|
||||
<li key={h} className="flex items-start gap-2 text-[12.5px] text-slate-700">
|
||||
<CheckCircle2Icon className="w-3.5 h-3.5 text-emerald-500 mt-0.5 shrink-0" />
|
||||
<span>{h}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isOAuth && entry.scopes && entry.scopes.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<SectionLabel>Permissions requested</SectionLabel>
|
||||
<div className="rounded-md border border-slate-200 bg-slate-50/70 divide-y divide-slate-200">
|
||||
{entry.scopes.map((s) => (
|
||||
<div key={s} className="flex items-center gap-2 px-2.5 py-1.5">
|
||||
<ShieldCheckIcon className="w-3 h-3 text-slate-400 shrink-0" />
|
||||
<code className="text-[10.5px] text-slate-600 font-mono truncate">{s}</code>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-[10.5px] text-slate-400 leading-relaxed flex items-center gap-1">
|
||||
<LockIcon className="w-3 h-3" />
|
||||
Tokens are encrypted at rest with your organization key. We never store your password.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<Label>Connection label (optional)</Label>
|
||||
<TextInput value={label} onChange={setLabel} placeholder={entry.name} />
|
||||
<p className="text-[10.5px] text-slate-400 mt-1">
|
||||
Useful if you connect more than one {entry.name} account.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{notConfigured && (
|
||||
<div className="rounded-md border border-amber-200 bg-amber-50 px-3 py-2.5">
|
||||
<p className="text-[12px] text-amber-800 leading-relaxed">
|
||||
{entry.name} OAuth isn’t enabled on this workspace yet. An admin needs to add the{" "}
|
||||
{entry.name} app credentials. Reach out and we’ll switch it on.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DrawerFooter onClose={onClose}>
|
||||
{isOAuth ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy || notConfigured}
|
||||
onClick={runOAuth}
|
||||
className={cn(primaryBtn, (busy || notConfigured) && "opacity-60 cursor-not-allowed")}
|
||||
>
|
||||
{busy ? <Loader2Icon className="w-3.5 h-3.5 animate-spin" /> : <ZapIcon className="w-3.5 h-3.5" />}
|
||||
{busy ? "Connecting…" : `Connect with ${entry.name}`}
|
||||
</button>
|
||||
) : isInbound ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => void submitCredentials(new Event("submit") as unknown as React.FormEvent)}
|
||||
className={cn(primaryBtn, busy && "opacity-60")}
|
||||
>
|
||||
{busy ? <Loader2Icon className="w-3.5 h-3.5 animate-spin" /> : <ArrowRightIcon className="w-3.5 h-3.5" />}
|
||||
{busy ? "Creating…" : "Create inbound URL"}
|
||||
</button>
|
||||
) : (
|
||||
<button type="button" onClick={() => setStep("credentials")} className={primaryBtn}>
|
||||
<KeyRoundIcon className="w-3.5 h-3.5" />
|
||||
Continue
|
||||
</button>
|
||||
)}
|
||||
</DrawerFooter>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === "credentials" && (
|
||||
<form onSubmit={submitCredentials} className="flex-1 overflow-auto flex flex-col">
|
||||
<div className="px-5 py-5 space-y-4">
|
||||
{fields.map((f) => (
|
||||
<div key={f.key}>
|
||||
<Label>
|
||||
{f.label}
|
||||
{f.required && <span className="text-rose-500 ml-0.5">*</span>}
|
||||
</Label>
|
||||
<TextInput
|
||||
type={f.type ?? "text"}
|
||||
value={config[f.key] ?? ""}
|
||||
onChange={(v) => update(f.key, v)}
|
||||
placeholder={f.placeholder}
|
||||
className="font-mono"
|
||||
/>
|
||||
{f.helper && (
|
||||
<p className="text-[10.5px] text-slate-400 mt-1 leading-relaxed">{f.helper}</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{entry.docs_url && (
|
||||
<a
|
||||
href={entry.docs_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 text-[11px] text-sky-700 hover:underline"
|
||||
>
|
||||
<ExternalLinkIcon className="w-3 h-3" />
|
||||
{entry.name} docs
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
<DrawerFooter onClose={() => setStep("overview")} cancelLabel="Back">
|
||||
<button type="submit" disabled={busy} className={cn(primaryBtn, busy && "opacity-60")}>
|
||||
{busy ? <Loader2Icon className="w-3.5 h-3.5 animate-spin" /> : <CheckCircle2Icon className="w-3.5 h-3.5" />}
|
||||
{busy ? "Connecting…" : "Connect"}
|
||||
</button>
|
||||
</DrawerFooter>
|
||||
</form>
|
||||
)}
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
// --- shared drawer chrome (also used by ConnectionDetail) -------------------
|
||||
|
||||
export function Drawer({
|
||||
title,
|
||||
name,
|
||||
provider,
|
||||
onClose,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
name: string;
|
||||
provider: string;
|
||||
onClose: () => void;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-40 flex">
|
||||
<button
|
||||
@@ -139,14 +337,12 @@ export default function ConnectDrawer({
|
||||
onClick={onClose}
|
||||
className="absolute inset-0 bg-slate-900/30 backdrop-blur-[2px]"
|
||||
/>
|
||||
<div className="ml-auto h-full w-[480px] bg-white shadow-xl flex flex-col z-10 relative">
|
||||
<div className="ml-auto h-full w-[480px] max-w-[92vw] bg-white shadow-xl flex flex-col z-10 relative animate-[slidein_.18s_ease-out]">
|
||||
<div className="h-12 px-5 border-b border-slate-200 flex items-center gap-3 shrink-0">
|
||||
<div className="w-7 h-7 rounded bg-sky-50 ring-1 ring-sky-100 text-sky-700 inline-flex items-center justify-center text-[12px] font-semibold uppercase">
|
||||
{entry.name.charAt(0)}
|
||||
</div>
|
||||
<ProviderGlyph provider={provider} name={name} size={7} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-[10px] uppercase tracking-[0.14em] text-slate-400 font-medium">Connect</div>
|
||||
<div className="text-[12.5px] text-slate-900 font-medium truncate">{entry.name}</div>
|
||||
<div className="text-[10px] uppercase tracking-[0.14em] text-slate-400 font-medium">{title}</div>
|
||||
<div className="text-[12.5px] text-slate-900 font-medium truncate">{name}</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@@ -157,75 +353,45 @@ export default function ConnectDrawer({
|
||||
<XIcon className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={submit} className="flex-1 overflow-auto flex flex-col">
|
||||
<div className="px-5 py-5 space-y-4">
|
||||
<p className="text-[12.5px] text-slate-600 leading-relaxed">{entry.tagline}</p>
|
||||
|
||||
<div>
|
||||
<label className="text-[10.5px] uppercase tracking-[0.08em] text-slate-400 font-medium">
|
||||
Label (optional)
|
||||
</label>
|
||||
<input
|
||||
value={label}
|
||||
onChange={(e) => setLabel(e.target.value)}
|
||||
placeholder={entry.name}
|
||||
className="mt-1 w-full h-8 px-2.5 rounded border border-slate-200 bg-white text-[12.5px] text-slate-900 placeholder:text-slate-400 outline-none focus:border-sky-400 transition-colors"
|
||||
/>
|
||||
<p className="text-[10.5px] text-slate-400 mt-1">
|
||||
Useful if you connect more than one of the same provider.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{fields.map((f) => (
|
||||
<div key={f.key}>
|
||||
<label className="text-[10.5px] uppercase tracking-[0.08em] text-slate-400 font-medium">
|
||||
{f.label}
|
||||
{f.required && <span className="text-rose-500 ml-0.5">*</span>}
|
||||
</label>
|
||||
<input
|
||||
type={f.type ?? "text"}
|
||||
value={config[f.key] ?? ""}
|
||||
onChange={(e) => update(f.key, e.target.value)}
|
||||
placeholder={f.placeholder}
|
||||
className="mt-1 w-full h-8 px-2.5 rounded border border-slate-200 bg-white text-[12.5px] text-slate-900 placeholder:text-slate-400 outline-none focus:border-sky-400 font-mono transition-colors"
|
||||
/>
|
||||
{f.helper && (
|
||||
<p className="text-[10.5px] text-slate-400 mt-1 leading-relaxed">{f.helper}</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{fields.length === 0 && (
|
||||
<div className="rounded border border-slate-200 bg-slate-50 px-3 py-2.5">
|
||||
<p className="text-[12px] text-slate-600 leading-relaxed">
|
||||
{entry.webhook_hint ?? "We will mint a URL for you on the next screen. Paste it into the provider's webhook configuration."}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-auto border-t border-slate-200 px-5 py-3 flex items-center justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="h-7 px-3 rounded border border-slate-200 text-[12px] text-slate-700 hover:border-slate-300 hover:text-slate-900 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={connect.isPending}
|
||||
className={cn(
|
||||
"h-7 px-3 rounded text-[12px] font-medium text-white transition-colors",
|
||||
connect.isPending ? "bg-sky-400" : "bg-sky-600 hover:bg-sky-700",
|
||||
)}
|
||||
>
|
||||
{connect.isPending ? "Connecting…" : "Connect"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DrawerFooter({
|
||||
onClose,
|
||||
cancelLabel = "Cancel",
|
||||
children,
|
||||
}: {
|
||||
onClose: () => void;
|
||||
cancelLabel?: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="mt-auto border-t border-slate-200 px-5 py-3 flex items-center justify-end gap-2 shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="h-7 px-3 rounded-md border border-slate-200 text-[12px] text-slate-700 hover:border-slate-300 hover:text-slate-900 transition-colors"
|
||||
>
|
||||
{cancelLabel}
|
||||
</button>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SectionLabel({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="text-[10px] uppercase tracking-[0.14em] text-slate-400 font-medium">{children}</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const primaryBtn =
|
||||
"h-7 px-3 rounded-md bg-sky-600 hover:bg-sky-700 text-white text-[12px] font-medium inline-flex items-center gap-1.5 transition-colors";
|
||||
|
||||
function errMessage(err: unknown): string | undefined {
|
||||
const e = err as { response?: { data?: { message?: string; error?: string } }; message?: string };
|
||||
return e.response?.data?.message ?? e.response?.data?.error ?? e.message;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,513 @@
|
||||
// Connection-management drawer — this is HOW a user actually uses an
|
||||
// integration after connecting. It surfaces lifecycle + health, the connected
|
||||
// account, granted scopes, recent activity (sync runs), and the automation
|
||||
// rules wired to this connection:
|
||||
//
|
||||
// "When a prospect replies (positive, ≥60% confidence) → notify #sales
|
||||
// with: 🔥 {{contact_email}} is interested — {{subject}}"
|
||||
//
|
||||
// Each rule is fully customizable: trigger event, filters (reply intent + min
|
||||
// confidence), destination (Slack channel / Sheet ID / webhook), and a custom
|
||||
// message template with {{placeholder}} substitution. Reauthorize / disconnect
|
||||
// live here too.
|
||||
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import {
|
||||
AlertTriangleIcon,
|
||||
CheckCircle2Icon,
|
||||
Loader2Icon,
|
||||
PlusIcon,
|
||||
RefreshCwIcon,
|
||||
Trash2Icon,
|
||||
UnplugIcon,
|
||||
ZapIcon,
|
||||
} from "lucide-react";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import { Label, TextInput } from "@/components/ui/field";
|
||||
import useConnectionDetail from "@/lib/api/hooks/app/integrations/useConnectionDetail";
|
||||
import useDisconnectIntegration from "@/lib/api/hooks/app/integrations/useDisconnectIntegration";
|
||||
import {
|
||||
useFinishIntegrationOAuth,
|
||||
useReauthIntegration,
|
||||
} from "@/lib/api/hooks/app/integrations/useIntegrationOAuth";
|
||||
import {
|
||||
useCreateConnectionEvent,
|
||||
useDeleteConnectionEvent,
|
||||
} from "@/lib/api/hooks/app/integrations/useConnectionEvents";
|
||||
import { openOAuthPopup } from "@/lib/integrations/oauthPopup";
|
||||
import {
|
||||
EVENT_LABELS,
|
||||
REPLY_INTENT_OPTIONS,
|
||||
type IntegrationCatalogEntry,
|
||||
type IntegrationConnection,
|
||||
type IntegrationEventSubscription,
|
||||
} from "@/lib/api/models/app/integrations/Integration";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { Drawer, SectionLabel } from "./ConnectDrawer";
|
||||
import StatusPill, { HealthDot } from "./StatusPill";
|
||||
|
||||
const REPLY_EVENT = "campaign.reply_received";
|
||||
|
||||
export default function ConnectionDetail({
|
||||
connection,
|
||||
entry,
|
||||
onClose,
|
||||
}: {
|
||||
connection: IntegrationConnection;
|
||||
entry?: IntegrationCatalogEntry;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const detail = useConnectionDetail(connection.id);
|
||||
const disconnect = useDisconnectIntegration();
|
||||
const reauth = useReauthIntegration();
|
||||
const finishOAuth = useFinishIntegrationOAuth();
|
||||
const createEvent = useCreateConnectionEvent();
|
||||
const deleteEvent = useDeleteConnectionEvent();
|
||||
|
||||
const [adding, setAdding] = React.useState(false);
|
||||
const [busy, setBusy] = React.useState(false);
|
||||
|
||||
const conn = detail.data?.connection ?? connection;
|
||||
const events = detail.data?.events ?? [];
|
||||
const runs = detail.data?.runs ?? [];
|
||||
|
||||
const availableEvents = entry?.events ?? Object.keys(EVENT_LABELS);
|
||||
const isOAuth = conn.auth_method === "oauth";
|
||||
const needsReauth = conn.status === "reauth_required";
|
||||
|
||||
async function handleReauth() {
|
||||
setBusy(true);
|
||||
try {
|
||||
const { url } = await reauth.mutateAsync(conn.id);
|
||||
const { code, state } = await openOAuthPopup(url);
|
||||
await finishOAuth.mutateAsync({ code, state });
|
||||
toast.success("Reconnected");
|
||||
detail.refetch();
|
||||
} catch (err: unknown) {
|
||||
toast.error(msg(err) ?? "Reconnect failed");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDisconnect() {
|
||||
if (!window.confirm(`Disconnect ${conn.label}? Automations using it will stop.`)) return;
|
||||
try {
|
||||
await disconnect.mutateAsync(conn.id);
|
||||
toast.success("Disconnected");
|
||||
onClose();
|
||||
} catch {
|
||||
toast.error("Disconnect failed");
|
||||
}
|
||||
}
|
||||
|
||||
async function addAutomation(eventType: string, config: Record<string, unknown>) {
|
||||
try {
|
||||
await createEvent.mutateAsync({
|
||||
connectionId: conn.id,
|
||||
event_type: eventType,
|
||||
action: actionForProvider(conn.provider),
|
||||
config,
|
||||
enabled: true,
|
||||
});
|
||||
toast.success("Automation added");
|
||||
setAdding(false);
|
||||
detail.refetch();
|
||||
} catch (err: unknown) {
|
||||
toast.error(msg(err) ?? "Could not add automation");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Drawer title="Manage" name={conn.label} provider={conn.provider} onClose={onClose}>
|
||||
<div className="flex-1 overflow-auto">
|
||||
{/* Status header */}
|
||||
<div className="px-5 py-4 border-b border-slate-200 space-y-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<StatusPill status={conn.status} />
|
||||
<div className="flex items-center gap-1.5 text-[11px] text-slate-500">
|
||||
<HealthDot health={conn.health} />
|
||||
{conn.health}
|
||||
</div>
|
||||
</div>
|
||||
{conn.external_account_name && <Row label="Account" value={conn.external_account_name} />}
|
||||
<Row label="Auth" value={conn.auth_method.replace("_", " ")} mono />
|
||||
<Row
|
||||
label="Last sync"
|
||||
value={conn.last_synced_at ? new Date(conn.last_synced_at).toLocaleString() : "never"}
|
||||
/>
|
||||
{conn.last_error && (
|
||||
<div className="rounded-md border border-rose-200 bg-rose-50 px-2.5 py-2 flex items-start gap-2">
|
||||
<AlertTriangleIcon className="w-3.5 h-3.5 text-rose-500 mt-0.5 shrink-0" />
|
||||
<p className="text-[11px] text-rose-700 leading-relaxed break-words">{conn.last_error}</p>
|
||||
</div>
|
||||
)}
|
||||
{needsReauth && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleReauth}
|
||||
disabled={busy}
|
||||
className="w-full h-8 rounded-md bg-amber-500 hover:bg-amber-600 text-white text-[12px] font-medium inline-flex items-center justify-center gap-1.5 transition-colors"
|
||||
>
|
||||
{busy ? <Loader2Icon className="w-3.5 h-3.5 animate-spin" /> : <RefreshCwIcon className="w-3.5 h-3.5" />}
|
||||
Reconnect to fix
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Granted access */}
|
||||
{conn.granted_scopes && conn.granted_scopes.length > 0 && (
|
||||
<div className="px-5 py-4 border-b border-slate-200 space-y-2">
|
||||
<SectionLabel>Granted access</SectionLabel>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{conn.granted_scopes.map((s) => (
|
||||
<span
|
||||
key={s}
|
||||
className="px-1.5 h-5 inline-flex items-center rounded bg-slate-100 text-[10px] font-mono text-slate-600"
|
||||
>
|
||||
{s}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Automations — the core "how you use it" surface */}
|
||||
<div className="px-5 py-4 border-b border-slate-200 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<SectionLabel>Automations</SectionLabel>
|
||||
{!adding && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAdding(true)}
|
||||
className="h-6 px-2 rounded text-[11px] text-sky-700 hover:bg-sky-50 inline-flex items-center gap-1 transition-colors"
|
||||
>
|
||||
<PlusIcon className="w-3 h-3" />
|
||||
New rule
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{events.length === 0 && !adding && (
|
||||
<p className="text-[11.5px] text-slate-400 leading-relaxed">
|
||||
No automations yet. Add a rule to push Warmbly events into {entry?.name ?? conn.label} —
|
||||
e.g. ping a channel when a prospect replies.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{events.map((ev) => (
|
||||
<AutomationRow
|
||||
key={ev.id}
|
||||
sub={ev}
|
||||
onDelete={() =>
|
||||
deleteEvent
|
||||
.mutateAsync({ connectionId: conn.id, eventId: ev.id })
|
||||
.then(() => detail.refetch())
|
||||
}
|
||||
/>
|
||||
))}
|
||||
|
||||
{adding && (
|
||||
<AddAutomation
|
||||
provider={conn.provider}
|
||||
availableEvents={availableEvents}
|
||||
onCancel={() => setAdding(false)}
|
||||
onAdd={addAutomation}
|
||||
busy={createEvent.isPending}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Activity */}
|
||||
<div className="px-5 py-4 space-y-2">
|
||||
<SectionLabel>Recent activity</SectionLabel>
|
||||
{runs.length === 0 ? (
|
||||
<p className="text-[11.5px] text-slate-400">Nothing yet.</p>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{runs.map((r) => (
|
||||
<div key={r.id} className="flex items-center gap-2 text-[11px]">
|
||||
{r.status === "success" ? (
|
||||
<CheckCircle2Icon className="w-3 h-3 text-emerald-500 shrink-0" />
|
||||
) : r.status === "error" ? (
|
||||
<AlertTriangleIcon className="w-3 h-3 text-rose-500 shrink-0" />
|
||||
) : (
|
||||
<Loader2Icon className="w-3 h-3 text-slate-400 animate-spin shrink-0" />
|
||||
)}
|
||||
<span className="text-slate-600 truncate flex-1">
|
||||
{r.kind}
|
||||
{r.detail ? ` · ${r.detail}` : ""}
|
||||
</span>
|
||||
<span className="text-slate-400 tabular-nums shrink-0">
|
||||
{new Date(r.started_at).toLocaleTimeString()}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-auto border-t border-slate-200 px-5 py-3 flex items-center justify-between shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDisconnect}
|
||||
className="h-7 px-3 rounded-md text-[12px] text-rose-600 hover:bg-rose-50 inline-flex items-center gap-1.5 transition-colors"
|
||||
>
|
||||
<UnplugIcon className="w-3.5 h-3.5" />
|
||||
Disconnect
|
||||
</button>
|
||||
{isOAuth && !needsReauth && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleReauth}
|
||||
disabled={busy}
|
||||
className="h-7 px-3 rounded-md border border-slate-200 text-[12px] text-slate-700 hover:border-slate-300 inline-flex items-center gap-1.5 transition-colors"
|
||||
>
|
||||
{busy ? <Loader2Icon className="w-3.5 h-3.5 animate-spin" /> : <RefreshCwIcon className="w-3.5 h-3.5" />}
|
||||
Reauthorize
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
// AutomationRow renders one configured rule with a human summary of its
|
||||
// trigger, filters, destination, and custom message.
|
||||
function AutomationRow({ sub, onDelete }: { sub: IntegrationEventSubscription; onDelete: () => void }) {
|
||||
const cfg = (sub.config ?? {}) as Record<string, unknown>;
|
||||
const intents = Array.isArray(cfg.intents) ? (cfg.intents as string[]) : [];
|
||||
const minConf = typeof cfg.min_confidence === "number" ? cfg.min_confidence : undefined;
|
||||
const dest =
|
||||
(cfg.channel as string) || (cfg.sheet_id as string) || (cfg.url as string) || (cfg.webhook_url as string) || "";
|
||||
const tmpl = (cfg.message_template as string) || "";
|
||||
|
||||
const filters: string[] = [];
|
||||
if (intents.length) filters.push(intents.join(", "));
|
||||
if (minConf) filters.push(`≥${Math.round(minConf * 100)}%`);
|
||||
|
||||
return (
|
||||
<div className="rounded-md border border-slate-200 px-2.5 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<ZapIcon className="w-3.5 h-3.5 text-sky-500 shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-[12px] text-slate-800 truncate">
|
||||
{EVENT_LABELS[sub.event_type] ?? sub.event_type}
|
||||
</div>
|
||||
<div className="text-[10px] text-slate-400 font-mono truncate">
|
||||
{dest || sub.action}
|
||||
{filters.length > 0 && ` · ${filters.join(" · ")}`}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDelete}
|
||||
aria-label="Remove automation"
|
||||
className="h-6 w-6 rounded text-slate-400 hover:text-rose-600 hover:bg-rose-50 inline-flex items-center justify-center transition-colors"
|
||||
>
|
||||
<Trash2Icon className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
{tmpl && (
|
||||
<p className="mt-1.5 pl-5 text-[10.5px] text-slate-500 italic truncate">“{tmpl}”</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// AddAutomation is the rule builder. Everything a user needs to customize a
|
||||
// behaviour lives here: trigger, reply-intent + confidence filters, the
|
||||
// destination, and a custom message template.
|
||||
function AddAutomation({
|
||||
provider,
|
||||
availableEvents,
|
||||
onAdd,
|
||||
onCancel,
|
||||
busy,
|
||||
}: {
|
||||
provider: string;
|
||||
availableEvents: string[];
|
||||
onAdd: (eventType: string, config: Record<string, unknown>) => void;
|
||||
onCancel: () => void;
|
||||
busy: boolean;
|
||||
}) {
|
||||
const [eventType, setEventType] = React.useState(
|
||||
availableEvents.includes(REPLY_EVENT) ? REPLY_EVENT : availableEvents[0] ?? REPLY_EVENT,
|
||||
);
|
||||
const [dest, setDest] = React.useState("");
|
||||
const [intents, setIntents] = React.useState<string[]>([]);
|
||||
const [minConf, setMinConf] = React.useState(0);
|
||||
const [template, setTemplate] = React.useState("");
|
||||
|
||||
const needsChannel = provider === "slack";
|
||||
const needsSheet = provider === "google_sheets";
|
||||
const needsURL = provider !== "slack" && provider !== "google_sheets" && provider !== "discord" &&
|
||||
provider !== "hubspot" && provider !== "pipedrive";
|
||||
const isReplyTrigger = eventType === REPLY_EVENT;
|
||||
const destRequired = needsChannel || needsURL;
|
||||
|
||||
function toggleIntent(v: string) {
|
||||
setIntents((cur) => (cur.includes(v) ? cur.filter((x) => x !== v) : [...cur, v]));
|
||||
}
|
||||
|
||||
function buildConfig(): Record<string, unknown> {
|
||||
const cfg: Record<string, unknown> = {};
|
||||
if (needsChannel && dest.trim()) cfg.channel = dest.trim();
|
||||
if (needsSheet && dest.trim()) cfg.sheet_id = dest.trim();
|
||||
if (needsURL && dest.trim()) cfg.url = dest.trim();
|
||||
if (isReplyTrigger && intents.length) cfg.intents = intents;
|
||||
if (isReplyTrigger && minConf > 0) cfg.min_confidence = minConf;
|
||||
if (template.trim()) cfg.message_template = template.trim();
|
||||
return cfg;
|
||||
}
|
||||
|
||||
const destLabel = needsChannel ? "Channel" : needsSheet ? "Sheet ID (optional)" : "Destination URL";
|
||||
const destPlaceholder = needsChannel ? "#sales" : needsSheet ? "1AbC…XyZ" : "https://…";
|
||||
const canSubmit = !busy && !(destRequired && !dest.trim());
|
||||
|
||||
return (
|
||||
<div className="rounded-md border border-sky-200 bg-sky-50/40 p-3 space-y-3">
|
||||
<div>
|
||||
<Label>When this happens</Label>
|
||||
<select
|
||||
value={eventType}
|
||||
onChange={(e) => setEventType(e.target.value)}
|
||||
className="w-full h-7 px-2 rounded-md border border-slate-200 bg-white text-[12.5px] text-slate-900 outline-none focus:border-sky-400 focus:ring-2 focus:ring-sky-100"
|
||||
>
|
||||
{availableEvents.map((ev) => (
|
||||
<option key={ev} value={ev}>
|
||||
{EVENT_LABELS[ev] ?? ev}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Reply-only filters */}
|
||||
{isReplyTrigger && (
|
||||
<div className="space-y-2">
|
||||
<Label>Only for these reply types (optional)</Label>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{REPLY_INTENT_OPTIONS.map((opt) => {
|
||||
const on = intents.includes(opt.value);
|
||||
return (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
onClick={() => toggleIntent(opt.value)}
|
||||
className={cn(
|
||||
"h-6 px-2 rounded-full text-[10.5px] border transition-colors",
|
||||
on
|
||||
? "bg-sky-600 border-sky-600 text-white"
|
||||
: "bg-white border-slate-200 text-slate-600 hover:border-slate-300",
|
||||
)}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div>
|
||||
<Label>Minimum confidence: {Math.round(minConf * 100)}%</Label>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={100}
|
||||
step={5}
|
||||
value={Math.round(minConf * 100)}
|
||||
onChange={(e) => setMinConf(Number(e.target.value) / 100)}
|
||||
className="w-full accent-sky-600"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(needsChannel || needsSheet || needsURL) && (
|
||||
<div>
|
||||
<Label>{destLabel}</Label>
|
||||
<TextInput
|
||||
value={dest}
|
||||
onChange={setDest}
|
||||
placeholder={destPlaceholder}
|
||||
className={needsSheet || needsURL ? "font-mono" : undefined}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Custom message (for notification-style actions) */}
|
||||
{(needsChannel || provider === "discord" || needsURL) && (
|
||||
<div>
|
||||
<Label>Custom message (optional)</Label>
|
||||
<textarea
|
||||
value={template}
|
||||
onChange={(e) => setTemplate(e.target.value)}
|
||||
rows={2}
|
||||
placeholder="🔥 {{contact_email}} replied — {{subject}}"
|
||||
className="w-full px-2 py-1.5 rounded-md border border-slate-200 bg-white text-[12px] text-slate-900 outline-none focus:border-sky-400 focus:ring-2 focus:ring-sky-100 resize-none"
|
||||
/>
|
||||
<p className="text-[10px] text-slate-400 mt-1">
|
||||
Use {"{{contact_email}}"}, {"{{subject}}"}, {"{{intent}}"}, {"{{campaign_id}}"}, {"{{reason}}"}.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-end gap-2 pt-0.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="h-6 px-2.5 rounded text-[11.5px] text-slate-600 hover:text-slate-900"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!canSubmit}
|
||||
onClick={() => onAdd(eventType, buildConfig())}
|
||||
className={cn(
|
||||
"h-6 px-2.5 rounded text-[11.5px] font-medium text-white bg-sky-600 hover:bg-sky-700 transition-colors",
|
||||
!canSubmit && "opacity-60",
|
||||
)}
|
||||
>
|
||||
{busy ? "Adding…" : "Add automation"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-[10.5px] uppercase tracking-[0.1em] text-slate-400">{label}</span>
|
||||
<span className={cn("text-[12px] text-slate-700 truncate", mono && "font-mono")}>{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// actionForProvider maps a connection's provider to the action its automations
|
||||
// perform. Mirrors defaultActionForProvider in the model module.
|
||||
function actionForProvider(provider: string): string {
|
||||
switch (provider) {
|
||||
case "slack":
|
||||
return "slack.notify";
|
||||
case "discord":
|
||||
return "discord.notify";
|
||||
case "hubspot":
|
||||
return "hubspot.upsert_contact";
|
||||
case "pipedrive":
|
||||
return "pipedrive.upsert_person";
|
||||
case "google_sheets":
|
||||
return "google_sheets.append_row";
|
||||
default:
|
||||
return "webhook.ping";
|
||||
}
|
||||
}
|
||||
|
||||
function msg(err: unknown): string | undefined {
|
||||
const e = err as { response?: { data?: { message?: string; error?: string } }; message?: string };
|
||||
return e.response?.data?.message ?? e.response?.data?.error ?? e.message;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
// A small branded glyph for each provider. We don't ship third-party logo
|
||||
// assets, so we render the provider initial on a per-brand tinted tile —
|
||||
// distinct enough to scan a directory quickly while staying on-theme.
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const BRAND: Record<string, { bg: string; ring: string; text: string }> = {
|
||||
hubspot: { bg: "bg-orange-50", ring: "ring-orange-200", text: "text-orange-600" },
|
||||
salesforce: { bg: "bg-sky-50", ring: "ring-sky-200", text: "text-sky-600" },
|
||||
pipedrive: { bg: "bg-slate-100", ring: "ring-slate-300", text: "text-slate-700" },
|
||||
close: { bg: "bg-indigo-50", ring: "ring-indigo-200", text: "text-indigo-600" },
|
||||
zapier: { bg: "bg-orange-50", ring: "ring-orange-200", text: "text-orange-600" },
|
||||
make: { bg: "bg-violet-50", ring: "ring-violet-200", text: "text-violet-600" },
|
||||
n8n: { bg: "bg-rose-50", ring: "ring-rose-200", text: "text-rose-600" },
|
||||
slack: { bg: "bg-fuchsia-50", ring: "ring-fuchsia-200", text: "text-fuchsia-600" },
|
||||
discord: { bg: "bg-indigo-50", ring: "ring-indigo-200", text: "text-indigo-600" },
|
||||
calendly: { bg: "bg-sky-50", ring: "ring-sky-200", text: "text-sky-600" },
|
||||
cal_com: { bg: "bg-slate-100", ring: "ring-slate-300", text: "text-slate-800" },
|
||||
google_sheets: { bg: "bg-emerald-50", ring: "ring-emerald-200", text: "text-emerald-600" },
|
||||
};
|
||||
|
||||
export default function ProviderGlyph({
|
||||
provider,
|
||||
name,
|
||||
size = 9,
|
||||
}: {
|
||||
provider: string;
|
||||
name: string;
|
||||
size?: 7 | 9 | 10;
|
||||
}) {
|
||||
const brand = BRAND[provider] ?? { bg: "bg-sky-50", ring: "ring-sky-100", text: "text-sky-700" };
|
||||
const dim = size === 7 ? "w-7 h-7 text-[12px]" : size === 10 ? "w-10 h-10 text-[15px]" : "w-9 h-9 text-[13px]";
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-md ring-1 inline-flex items-center justify-center font-semibold uppercase shrink-0",
|
||||
dim,
|
||||
brand.bg,
|
||||
brand.ring,
|
||||
brand.text,
|
||||
)}
|
||||
>
|
||||
{name.charAt(0)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { IntegrationHealth, IntegrationStatus } from "@/lib/api/models/app/integrations/Integration";
|
||||
|
||||
const TONES: Record<string, { bg: string; text: string; dot: string; label: string }> = {
|
||||
connected: { bg: "bg-emerald-50", text: "text-emerald-700", dot: "bg-emerald-500", label: "connected" },
|
||||
authorizing: { bg: "bg-sky-50", text: "text-sky-700", dot: "bg-sky-500", label: "authorizing" },
|
||||
pending: { bg: "bg-sky-50", text: "text-sky-700", dot: "bg-sky-500", label: "pending" },
|
||||
degraded: { bg: "bg-amber-50", text: "text-amber-700", dot: "bg-amber-500", label: "degraded" },
|
||||
reauth_required: { bg: "bg-amber-50", text: "text-amber-700", dot: "bg-amber-500", label: "reconnect" },
|
||||
disconnected: { bg: "bg-slate-100", text: "text-slate-500", dot: "bg-slate-400", label: "not connected" },
|
||||
};
|
||||
|
||||
export default function StatusPill({ status }: { status: IntegrationStatus | string }) {
|
||||
const tone = TONES[status] ?? TONES.disconnected;
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 h-5 px-1.5 rounded text-[9.5px] uppercase tracking-[0.08em] font-medium",
|
||||
tone.bg,
|
||||
tone.text,
|
||||
)}
|
||||
>
|
||||
<span className={cn("size-1.5 rounded-full", tone.dot)} />
|
||||
{tone.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const HEALTH_DOT: Record<string, string> = {
|
||||
healthy: "bg-emerald-500",
|
||||
degraded: "bg-amber-500",
|
||||
down: "bg-rose-500",
|
||||
unknown: "bg-slate-300",
|
||||
};
|
||||
|
||||
export function HealthDot({ health }: { health: IntegrationHealth | string }) {
|
||||
return <span className={cn("size-1.5 rounded-full", HEALTH_DOT[health] ?? HEALTH_DOT.unknown)} />;
|
||||
}
|
||||
@@ -1,26 +1,15 @@
|
||||
// Integrations dashboard.
|
||||
// Integrations marketplace.
|
||||
//
|
||||
// One page covers the integration surface: catalog of available providers
|
||||
// (HubSpot, Salesforce, Pipedrive, Close, Zapier, Make, n8n, Slack,
|
||||
// Discord, Calendly, Cal.com, Google Sheets), per-org connection state,
|
||||
// inbound webhook URLs, and meeting bookings.
|
||||
//
|
||||
// Layout follows the Page primitives: stat strip across the top, section
|
||||
// bars between zones, no max-width chrome. Connect / disconnect happens
|
||||
// in an inline drawer so the page stays a single navigation target from
|
||||
// the sidebar.
|
||||
// The connect experience is the point of this page: a searchable app directory
|
||||
// grouped by category, a "connected" rail across the top, a multi-step connect
|
||||
// drawer (one-click OAuth where the provider supports it), and a management
|
||||
// drawer for health, reauth, and event automations. Realtime keeps connection
|
||||
// state live without a manual refresh.
|
||||
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import {
|
||||
CableIcon,
|
||||
CalendarCheckIcon,
|
||||
CheckIcon,
|
||||
PlusIcon,
|
||||
RefreshCwIcon,
|
||||
XIcon,
|
||||
} from "lucide-react";
|
||||
import { CalendarCheckIcon, ExternalLinkIcon, RefreshCwIcon, SettingsIcon } from "lucide-react";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import {
|
||||
@@ -32,63 +21,73 @@ import {
|
||||
Stat,
|
||||
StatStrip,
|
||||
} from "@/components/layout/Page";
|
||||
import { SearchInput } from "@/components/ui/field";
|
||||
import useIntegrationCatalog from "@/lib/api/hooks/app/integrations/useIntegrationCatalog";
|
||||
import useIntegrationConnections from "@/lib/api/hooks/app/integrations/useIntegrationConnections";
|
||||
import useDisconnectIntegration from "@/lib/api/hooks/app/integrations/useDisconnectIntegration";
|
||||
import useMeetingBookings from "@/lib/api/hooks/app/integrations/useMeetingBookings";
|
||||
import type {
|
||||
IntegrationCatalogEntry,
|
||||
IntegrationCategory,
|
||||
IntegrationConnection,
|
||||
IntegrationProvider,
|
||||
import {
|
||||
CATEGORY_LABELS,
|
||||
CATEGORY_ORDER,
|
||||
type IntegrationCatalogEntry,
|
||||
type IntegrationCategory,
|
||||
type IntegrationConnection,
|
||||
type IntegrationProvider,
|
||||
} from "@/lib/api/models/app/integrations/Integration";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import ConnectDrawer from "./_components/ConnectDrawer";
|
||||
import ConnectionDetail from "./_components/ConnectionDetail";
|
||||
import InboundUrlDialog from "./_components/InboundUrlDialog";
|
||||
|
||||
const CATEGORY_LABELS: Record<IntegrationCategory, string> = {
|
||||
crm: "CRM",
|
||||
automation: "Automation",
|
||||
notifications: "Notifications",
|
||||
meetings: "Meetings",
|
||||
data: "Data",
|
||||
};
|
||||
|
||||
const CATEGORY_ORDER: IntegrationCategory[] = ["crm", "automation", "notifications", "meetings", "data"];
|
||||
import ProviderGlyph from "./_components/ProviderGlyph";
|
||||
import StatusPill from "./_components/StatusPill";
|
||||
|
||||
export default function IntegrationsPage() {
|
||||
const catalogQuery = useIntegrationCatalog();
|
||||
const connectionsQuery = useIntegrationConnections();
|
||||
const bookingsQuery = useMeetingBookings();
|
||||
|
||||
const disconnect = useDisconnectIntegration();
|
||||
|
||||
const [connectTarget, setConnectTarget] = React.useState<IntegrationCatalogEntry | null>(null);
|
||||
const [manageTarget, setManageTarget] = React.useState<IntegrationConnection | null>(null);
|
||||
const [inboundUrl, setInboundUrl] = React.useState<{ provider: IntegrationProvider; url: string } | null>(null);
|
||||
const [query, setQuery] = React.useState("");
|
||||
|
||||
const catalog = catalogQuery.data?.catalog ?? [];
|
||||
const connections = connectionsQuery.data?.connections ?? [];
|
||||
const bookings = bookingsQuery.data?.bookings ?? [];
|
||||
|
||||
const byProvider = React.useMemo(() => {
|
||||
const m: Record<string, IntegrationConnection[]> = {};
|
||||
for (const c of connections) {
|
||||
(m[c.provider] ??= []).push(c);
|
||||
}
|
||||
const entryByProvider = React.useMemo(() => {
|
||||
const m: Record<string, IntegrationCatalogEntry> = {};
|
||||
for (const e of catalog) m[e.provider] = e;
|
||||
return m;
|
||||
}, [catalog]);
|
||||
|
||||
const firstConnByProvider = React.useMemo(() => {
|
||||
const m: Record<string, IntegrationConnection> = {};
|
||||
for (const c of connections) if (!m[c.provider]) m[c.provider] = c;
|
||||
return m;
|
||||
}, [connections]);
|
||||
|
||||
const q = query.trim().toLowerCase();
|
||||
const filtered = React.useMemo(() => {
|
||||
if (!q) return catalog;
|
||||
return catalog.filter(
|
||||
(e) =>
|
||||
e.name.toLowerCase().includes(q) ||
|
||||
e.tagline.toLowerCase().includes(q) ||
|
||||
e.category.toLowerCase().includes(q),
|
||||
);
|
||||
}, [catalog, q]);
|
||||
|
||||
const grouped = React.useMemo(() => {
|
||||
const map: Partial<Record<IntegrationCategory, IntegrationCatalogEntry[]>> = {};
|
||||
for (const entry of catalog) {
|
||||
(map[entry.category] ??= []).push(entry);
|
||||
}
|
||||
for (const entry of filtered) (map[entry.category] ??= []).push(entry);
|
||||
return map;
|
||||
}, [catalog]);
|
||||
}, [filtered]);
|
||||
|
||||
const connectedCount = connections.filter((c) => c.status === "connected").length;
|
||||
const degradedCount = connections.filter((c) => c.status === "degraded").length;
|
||||
const attentionCount = connections.filter(
|
||||
(c) => c.status === "degraded" || c.status === "reauth_required",
|
||||
).length;
|
||||
|
||||
function refreshAll() {
|
||||
catalogQuery.refetch();
|
||||
@@ -96,54 +95,56 @@ export default function IntegrationsPage() {
|
||||
bookingsQuery.refetch();
|
||||
}
|
||||
|
||||
async function handleDisconnect(connection: IntegrationConnection) {
|
||||
try {
|
||||
await disconnect.mutateAsync(connection.id);
|
||||
toast.success("Disconnected");
|
||||
} catch {
|
||||
toast.error("Disconnect failed");
|
||||
}
|
||||
function onCardClick(entry: IntegrationCatalogEntry) {
|
||||
const existing = firstConnByProvider[entry.provider];
|
||||
if (existing) setManageTarget(existing);
|
||||
else setConnectTarget(entry);
|
||||
}
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<PageTopbar eyebrow="Integrations" subtitle="CRMs, automation, notifications, meetings, and data">
|
||||
<button
|
||||
type="button"
|
||||
onClick={refreshAll}
|
||||
aria-label="Refresh"
|
||||
className="h-7 w-7 rounded-md border border-slate-200 hover:border-slate-300 text-slate-500 hover:text-slate-900 inline-flex items-center justify-center transition-colors"
|
||||
>
|
||||
<RefreshCwIcon className={cn("w-3 h-3", connectionsQuery.isFetching && "animate-spin")} />
|
||||
</button>
|
||||
<PageTopbar eyebrow="Integrations" subtitle="Connect your stack — CRMs, alerts, automation, meetings, and data">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-48 hidden sm:block">
|
||||
<SearchInput value={query} onChange={setQuery} placeholder="Search integrations" />
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={refreshAll}
|
||||
aria-label="Refresh"
|
||||
className="h-7 w-7 rounded-md border border-slate-200 hover:border-slate-300 text-slate-500 hover:text-slate-900 inline-flex items-center justify-center transition-colors"
|
||||
>
|
||||
<RefreshCwIcon className={cn("w-3 h-3", connectionsQuery.isFetching && "animate-spin")} />
|
||||
</button>
|
||||
</div>
|
||||
</PageTopbar>
|
||||
|
||||
<StatStrip cols={4}>
|
||||
<Stat
|
||||
label="Catalog"
|
||||
value={catalog.length}
|
||||
sub="available providers"
|
||||
/>
|
||||
<Stat
|
||||
label="Connected"
|
||||
value={connectedCount}
|
||||
sub={`${connections.length} total`}
|
||||
accent={connectedCount > 0}
|
||||
/>
|
||||
<Stat
|
||||
label="Degraded"
|
||||
value={degradedCount}
|
||||
sub={degradedCount > 0 ? "needs attention" : "all healthy"}
|
||||
/>
|
||||
<Stat
|
||||
label="Meetings"
|
||||
value={bookings.length}
|
||||
sub="from Calendly + Cal.com"
|
||||
last
|
||||
/>
|
||||
<Stat label="Available" value={catalog.length} sub="providers" />
|
||||
<Stat label="Connected" value={connectedCount} sub={`${connections.length} total`} accent={connectedCount > 0} />
|
||||
<Stat label="Needs attention" value={attentionCount} sub={attentionCount > 0 ? "reconnect / errors" : "all healthy"} />
|
||||
<Stat label="Meetings" value={bookings.length} sub="booked via integrations" last />
|
||||
</StatStrip>
|
||||
|
||||
<PageBody>
|
||||
{/* Connected rail */}
|
||||
{connections.length > 0 && (
|
||||
<section>
|
||||
<SectionBar label="Your connections" count={connections.length} />
|
||||
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-px bg-slate-200/60 border-b border-slate-200/60">
|
||||
{connections.map((c) => (
|
||||
<ConnectionCard
|
||||
key={c.id}
|
||||
connection={c}
|
||||
entry={entryByProvider[c.provider]}
|
||||
onManage={() => setManageTarget(c)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Catalog by category */}
|
||||
{CATEGORY_ORDER.map((category) => {
|
||||
const entries = grouped[category] ?? [];
|
||||
if (entries.length === 0) return null;
|
||||
@@ -155,10 +156,8 @@ export default function IntegrationsPage() {
|
||||
<CatalogCard
|
||||
key={entry.provider}
|
||||
entry={entry}
|
||||
connections={byProvider[entry.provider] ?? []}
|
||||
onConnect={() => setConnectTarget(entry)}
|
||||
onDisconnect={handleDisconnect}
|
||||
onShowInbound={(url) => setInboundUrl({ provider: entry.provider, url })}
|
||||
connection={firstConnByProvider[entry.provider]}
|
||||
onClick={() => onCardClick(entry)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -166,19 +165,29 @@ export default function IntegrationsPage() {
|
||||
);
|
||||
})}
|
||||
|
||||
{q && filtered.length === 0 && (
|
||||
<EmptyBlock title="No matches" body={`Nothing in the catalog matches “${query}”.`} />
|
||||
)}
|
||||
|
||||
{/* Meetings */}
|
||||
<SectionBar label="Meeting bookings" count={bookings.length}>
|
||||
<CalendarCheckIcon className="w-3 h-3 text-slate-400" />
|
||||
</SectionBar>
|
||||
{bookings.length === 0 ? (
|
||||
<EmptyBlock
|
||||
title="No meetings booked yet"
|
||||
body="Connect Calendly or Cal.com to track which campaigns lead to meetings."
|
||||
body="Connect Calendly or Cal.com to credit booked meetings to the campaign that surfaced the lead."
|
||||
/>
|
||||
) : (
|
||||
<div className="divide-y divide-slate-200/60 border-b border-slate-200/60">
|
||||
{bookings.slice(0, 10).map((b) => (
|
||||
{bookings.slice(0, 12).map((b) => (
|
||||
<div key={b.id} className="px-5 h-12 flex items-center gap-3 text-[12.5px]">
|
||||
<SourceDot source={b.source} />
|
||||
<span
|
||||
className={cn(
|
||||
"size-1.5 rounded-full shrink-0",
|
||||
b.source === "calendly" ? "bg-rose-400" : "bg-indigo-400",
|
||||
)}
|
||||
/>
|
||||
<span className="font-medium text-slate-900 truncate w-32 sm:w-60">{b.invitee_email}</span>
|
||||
<span className="text-slate-500 truncate flex-1">{b.event_name}</span>
|
||||
<span className="font-mono text-[10.5px] text-slate-400 tabular-nums">
|
||||
@@ -195,12 +204,26 @@ export default function IntegrationsPage() {
|
||||
entry={connectTarget}
|
||||
onClose={() => setConnectTarget(null)}
|
||||
onConnected={(conn) => {
|
||||
connectionsQuery.refetch();
|
||||
if (conn.inbound_webhook_url) {
|
||||
setInboundUrl({ provider: conn.provider, url: conn.inbound_webhook_url });
|
||||
} else {
|
||||
// Drop straight into management so the user can wire automations.
|
||||
setManageTarget(conn);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{manageTarget && (
|
||||
<ConnectionDetail
|
||||
connection={manageTarget}
|
||||
entry={entryByProvider[manageTarget.provider]}
|
||||
onClose={() => {
|
||||
setManageTarget(null);
|
||||
connectionsQuery.refetch();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{inboundUrl && (
|
||||
<InboundUrlDialog
|
||||
provider={inboundUrl.provider}
|
||||
@@ -214,42 +237,36 @@ export default function IntegrationsPage() {
|
||||
|
||||
function CatalogCard({
|
||||
entry,
|
||||
connections,
|
||||
onConnect,
|
||||
onDisconnect,
|
||||
onShowInbound,
|
||||
connection,
|
||||
onClick,
|
||||
}: {
|
||||
entry: IntegrationCatalogEntry;
|
||||
connections: IntegrationConnection[];
|
||||
onConnect: () => void;
|
||||
onDisconnect: (c: IntegrationConnection) => void;
|
||||
onShowInbound: (url: string) => void;
|
||||
connection?: IntegrationConnection;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
const connected = connections.length > 0;
|
||||
const status = connected ? connections[0].status : "disconnected";
|
||||
const connected = !!connection;
|
||||
const comingSoon = entry.auth_method === "oauth" && !entry.configured && !connected;
|
||||
return (
|
||||
<div className="bg-white p-5 flex flex-col min-h-[140px]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className="text-left bg-white p-5 flex flex-col min-h-[150px] hover:bg-slate-50/60 transition-colors group"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-center gap-2.5 min-w-0">
|
||||
<div className="w-9 h-9 rounded-md bg-sky-50 ring-1 ring-sky-100 text-sky-700 inline-flex items-center justify-center text-[13px] font-semibold uppercase">
|
||||
{entry.name.charAt(0)}
|
||||
</div>
|
||||
<ProviderGlyph provider={entry.provider} name={entry.name} />
|
||||
<div className="min-w-0">
|
||||
<div className="text-[13px] font-semibold text-slate-900 truncate">{entry.name}</div>
|
||||
<div className="text-[10.5px] uppercase tracking-[0.08em] text-slate-400 font-mono">
|
||||
{entry.auth_method}
|
||||
{entry.beta && (
|
||||
<span className="ml-1.5 text-amber-600">· beta</span>
|
||||
)}
|
||||
<div className="text-[10px] uppercase tracking-[0.08em] text-slate-400 font-mono">
|
||||
{entry.auth_method === "oauth" ? "one-click" : entry.auth_method}
|
||||
{entry.beta && <span className="ml-1.5 text-amber-600">· beta</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<StatusPill status={status} />
|
||||
{connected ? <StatusPill status={connection.status} /> : comingSoon ? <ComingSoon /> : null}
|
||||
</div>
|
||||
|
||||
<p className="mt-3 text-[12px] text-slate-600 leading-relaxed line-clamp-3">
|
||||
{entry.tagline}
|
||||
</p>
|
||||
<p className="mt-3 text-[12px] text-slate-600 leading-relaxed line-clamp-2">{entry.tagline}</p>
|
||||
|
||||
<div className="mt-auto pt-3 flex items-center justify-between gap-2">
|
||||
{entry.docs_url ? (
|
||||
@@ -257,95 +274,76 @@ function CatalogCard({
|
||||
href={entry.docs_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-[11px] text-slate-500 hover:text-sky-700 underline decoration-dotted underline-offset-2"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="text-[11px] text-slate-400 hover:text-sky-700 inline-flex items-center gap-1 underline decoration-dotted underline-offset-2"
|
||||
>
|
||||
<ExternalLinkIcon className="w-3 h-3" />
|
||||
Docs
|
||||
</a>
|
||||
) : <span />}
|
||||
<div className="flex items-center gap-1.5">
|
||||
{connected && connections[0].id ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDisconnect(connections[0])}
|
||||
className="h-6 px-2 rounded text-[11px] text-slate-500 hover:text-rose-700 hover:bg-rose-50 transition-colors"
|
||||
>
|
||||
Disconnect
|
||||
</button>
|
||||
{connections[0].display_fields && Object.keys(connections[0].display_fields).length > 0 && (
|
||||
<span className="font-mono text-[10px] text-slate-400 truncate max-w-[120px]">
|
||||
{(connections[0].display_fields as Record<string, string>)["workspace"] ??
|
||||
(connections[0].display_fields as Record<string, string>)["sheet_title"] ??
|
||||
(connections[0].display_fields as Record<string, string>)["account_email"] ??
|
||||
(connections[0].display_fields as Record<string, string>)["channel"] ??
|
||||
""}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onConnect}
|
||||
className="h-7 px-2.5 rounded-md bg-sky-600 hover:bg-sky-700 text-white text-[11.5px] font-medium inline-flex items-center gap-1 transition-colors"
|
||||
>
|
||||
<PlusIcon className="w-3 h-3" />
|
||||
Connect
|
||||
</button>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
<span
|
||||
className={cn(
|
||||
"h-7 px-2.5 rounded-md text-[11.5px] font-medium inline-flex items-center gap-1 transition-colors",
|
||||
connected
|
||||
? "text-slate-600 group-hover:text-slate-900 group-hover:bg-slate-100"
|
||||
: comingSoon
|
||||
? "text-slate-300"
|
||||
: "bg-sky-600 text-white group-hover:bg-sky-700",
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{connected && entry.webhook_hint && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onShowInbound("/api/v1/integrations/inbound/" + entry.provider.replace("_", "-") + "/<your-secret>");
|
||||
}}
|
||||
className="mt-2 text-[10.5px] text-sky-700 hover:underline self-start inline-flex items-center gap-1"
|
||||
>
|
||||
<CableIcon className="w-3 h-3" />
|
||||
Webhook URL
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{connected ? (
|
||||
<>
|
||||
<SettingsIcon className="w-3 h-3" />
|
||||
Manage
|
||||
</>
|
||||
) : comingSoon ? (
|
||||
"Coming soon"
|
||||
) : (
|
||||
"Connect"
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusPill({ status }: { status: string }) {
|
||||
const tone =
|
||||
status === "connected"
|
||||
? { bg: "bg-emerald-50", text: "text-emerald-700", dot: "bg-emerald-500" }
|
||||
: status === "degraded"
|
||||
? { bg: "bg-amber-50", text: "text-amber-700", dot: "bg-amber-500" }
|
||||
: status === "pending"
|
||||
? { bg: "bg-sky-50", text: "text-sky-700", dot: "bg-sky-500" }
|
||||
: { bg: "bg-slate-100", text: "text-slate-500", dot: "bg-slate-400" };
|
||||
const label = status === "disconnected" ? "not connected" : status;
|
||||
function ConnectionCard({
|
||||
connection,
|
||||
entry,
|
||||
onManage,
|
||||
}: {
|
||||
connection: IntegrationConnection;
|
||||
entry?: IntegrationCatalogEntry;
|
||||
onManage: () => void;
|
||||
}) {
|
||||
const account =
|
||||
connection.external_account_name ||
|
||||
(connection.display_fields as Record<string, string>)?.account ||
|
||||
(connection.display_fields as Record<string, string>)?.workspace ||
|
||||
(connection.display_fields as Record<string, string>)?.channel ||
|
||||
"";
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 h-5 px-1.5 rounded text-[9.5px] uppercase tracking-[0.08em] font-medium",
|
||||
tone.bg,
|
||||
tone.text,
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onManage}
|
||||
className="text-left bg-white p-4 flex items-center gap-3 hover:bg-slate-50/60 transition-colors"
|
||||
>
|
||||
<span className={cn("size-1.5 rounded-full", tone.dot)} />
|
||||
{label}
|
||||
</span>
|
||||
<ProviderGlyph provider={connection.provider} name={entry?.name ?? connection.label} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-[12.5px] font-semibold text-slate-900 truncate">{connection.label}</div>
|
||||
<div className="text-[11px] text-slate-400 truncate">{account || (entry?.name ?? connection.provider)}</div>
|
||||
</div>
|
||||
<StatusPill status={connection.status} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function SourceDot({ source }: { source: string }) {
|
||||
const colour = source === "calendly" ? "bg-rose-400" : "bg-indigo-400";
|
||||
function ComingSoon() {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<span className={cn("size-1.5 rounded-full", colour)} />
|
||||
<span className="text-[10px] uppercase tracking-[0.08em] text-slate-400 font-mono">
|
||||
{source === "calendly" ? "calendly" : "cal.com"}
|
||||
</span>
|
||||
<span className="inline-flex items-center h-5 px-1.5 rounded text-[9.5px] uppercase tracking-[0.08em] font-medium bg-slate-100 text-slate-400">
|
||||
soon
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
void CheckIcon;
|
||||
void XIcon;
|
||||
|
||||
@@ -101,7 +101,7 @@ export default function MembersSettingsPage() {
|
||||
try {
|
||||
await toast.promise(
|
||||
updateRole.mutateAsync({
|
||||
memberId,
|
||||
id: memberId,
|
||||
data: { role: nextRole },
|
||||
}),
|
||||
{
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Loading } from "@/components/loader";
|
||||
import { useError } from "@/hooks/ErrorProvider";
|
||||
import type { Tag} from "@/hooks/UserProvider";
|
||||
import { User, useUser } from "@/hooks/UserProvider";
|
||||
import type { Tag } from "@/hooks/UserProvider";
|
||||
import { useUser } from "@/hooks/UserProvider";
|
||||
import { APIError, Call } from "@/lib/api";
|
||||
import {
|
||||
DndContext,
|
||||
@@ -368,4 +368,4 @@ const ColorInput = ({color, setColor}:{color: string, setColor: React.Dispatch<R
|
||||
</ColorBox>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { UserContext } from './context/user';
|
||||
import useUser from '@/lib/api/hooks/auth/useUser';
|
||||
import { useUserProfile } from './context/user';
|
||||
import useAuthUser from '@/lib/api/hooks/auth/useUser';
|
||||
import { AnimatePresence } from 'framer-motion';
|
||||
import LoadingScreen from '@/components/LoadingScreen';
|
||||
import useRoles from '@/lib/api/hooks/app/admin/roles/useRoles';
|
||||
@@ -17,7 +19,8 @@ const EMPTY_ACCESS: Access = { roles: [], permissions: [] };
|
||||
const EMPTY_TIMEZONES: Timezone[] = [];
|
||||
|
||||
export const UserProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
const user = useUser();
|
||||
const queryClient = useQueryClient();
|
||||
const user = useAuthUser();
|
||||
const access = useRoles();
|
||||
const timezones = useTimezones();
|
||||
|
||||
@@ -56,6 +59,14 @@ export const UserProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
}
|
||||
}, [user.error, access.error, timezones.error]);
|
||||
|
||||
const setUser = React.useCallback<React.Dispatch<React.SetStateAction<User | null>>>((value) => {
|
||||
queryClient.setQueryData<User | null>(["auth", "me"], (old) => (
|
||||
typeof value === "function"
|
||||
? (value as (prev: User | null) => User | null)(old ?? null)
|
||||
: value
|
||||
));
|
||||
}, [queryClient]);
|
||||
|
||||
if (error?.redirect) {
|
||||
clearTokens();
|
||||
return <Navigate to="/auth/login" replace />;
|
||||
@@ -87,6 +98,7 @@ export const UserProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
user: safeUser,
|
||||
access: access.data ?? EMPTY_ACCESS,
|
||||
timezones: timezones.data ?? EMPTY_TIMEZONES,
|
||||
setUser,
|
||||
tagsEdit,
|
||||
setTagsEdit,
|
||||
foldersEdit,
|
||||
@@ -98,3 +110,8 @@ export const UserProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
</UserContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export { useUserProfile as useUser };
|
||||
export type { default as Folder } from "@/lib/api/models/app/Folder";
|
||||
export type { default as Tag } from "@/lib/api/models/app/Tag";
|
||||
export type { default as User } from "@/lib/api/models/auth/User";
|
||||
|
||||
@@ -26,6 +26,8 @@ interface SocketContextValue {
|
||||
// Connection state
|
||||
isConnected: boolean;
|
||||
reconnectAttempt: number;
|
||||
error?: boolean;
|
||||
message?: string;
|
||||
|
||||
// Channel management
|
||||
joinChannel: (topic: string, params?: Record<string, unknown>) => void;
|
||||
|
||||
@@ -7,6 +7,7 @@ interface UserC {
|
||||
user: User;
|
||||
access: Access;
|
||||
timezones: Timezone[];
|
||||
setUser: React.Dispatch<React.SetStateAction<User | null>>;
|
||||
tagsEdit: boolean;
|
||||
setTagsEdit: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
foldersEdit: boolean;
|
||||
|
||||
@@ -162,6 +162,17 @@ export function useRealtimeEvents() {
|
||||
return
|
||||
}
|
||||
|
||||
if (includes('INTEGRATION', 'CONNECTION', 'BOOKING', 'MEETING')) {
|
||||
invalidate([
|
||||
['integrations', 'connections'],
|
||||
['integrations', 'catalog'],
|
||||
['integrations', 'bookings'],
|
||||
])
|
||||
const connectionId = getString('connection_id')
|
||||
if (connectionId) invalidate([['integrations', 'connection', connectionId]])
|
||||
return
|
||||
}
|
||||
|
||||
if (includes('TEMPLATE')) {
|
||||
invalidate([['templates']])
|
||||
return
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { IntegrationEventSubscription } from "@/lib/api/models/app/integrations/Integration";
|
||||
import Request from "../../Request";
|
||||
|
||||
export interface CreateConnectionEventInput {
|
||||
connectionId: string;
|
||||
event_type: string;
|
||||
action: string;
|
||||
config?: Record<string, unknown>;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
// Wires a Warmbly event (e.g. campaign.reply_received) to a provider action
|
||||
// (e.g. slack.notify) on a connection.
|
||||
export default async function createConnectionEvent(
|
||||
input: CreateConnectionEventInput,
|
||||
): Promise<IntegrationEventSubscription> {
|
||||
const { connectionId, ...body } = input;
|
||||
return await Request<IntegrationEventSubscription>({
|
||||
method: "POST",
|
||||
url: `/integrations/connections/${connectionId}/events`,
|
||||
data: body,
|
||||
authorization: true,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import Request from "../../Request";
|
||||
|
||||
// Removes an event→action subscription from a connection.
|
||||
export default async function deleteConnectionEvent(input: {
|
||||
connectionId: string;
|
||||
eventId: string;
|
||||
}): Promise<void> {
|
||||
await Request<void>({
|
||||
method: "DELETE",
|
||||
url: `/integrations/connections/${input.connectionId}/events/${input.eventId}`,
|
||||
authorization: true,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { IntegrationConnection } from "@/lib/api/models/app/integrations/Integration";
|
||||
import Request from "../../Request";
|
||||
|
||||
// Completes the OAuth handshake: the backend validates state, exchanges the
|
||||
// code, resolves the connected account, and stores encrypted tokens.
|
||||
export default async function finishIntegrationOAuth(input: {
|
||||
code: string;
|
||||
state: string;
|
||||
}): Promise<IntegrationConnection> {
|
||||
return await Request<IntegrationConnection>({
|
||||
method: "POST",
|
||||
url: "/integrations/oauth/finish",
|
||||
data: input,
|
||||
authorization: true,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { IntegrationConnectionDetail } from "@/lib/api/models/app/integrations/Integration";
|
||||
import Request from "../../Request";
|
||||
|
||||
// Returns a single connection plus its event subscriptions and recent sync
|
||||
// runs — the connection-management drawer payload.
|
||||
export default async function getConnection(id: string): Promise<IntegrationConnectionDetail> {
|
||||
return await Request<IntegrationConnectionDetail>({
|
||||
method: "GET",
|
||||
url: `/integrations/connections/${id}`,
|
||||
authorization: true,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { IntegrationOAuthStartResponse } from "@/lib/api/models/app/integrations/Integration";
|
||||
import Request from "../../Request";
|
||||
|
||||
// Starts a fresh OAuth handshake for an existing connection whose token
|
||||
// expired or was revoked. Returns a new authorization URL.
|
||||
export default async function reauthIntegration(id: string): Promise<IntegrationOAuthStartResponse> {
|
||||
return await Request<IntegrationOAuthStartResponse>({
|
||||
method: "POST",
|
||||
url: `/integrations/oauth/reauth/${id}`,
|
||||
data: {},
|
||||
authorization: true,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { IntegrationOAuthStartResponse } from "@/lib/api/models/app/integrations/Integration";
|
||||
import Request from "../../Request";
|
||||
|
||||
// Starts an OAuth handshake. Returns the provider authorization URL the SPA
|
||||
// opens in a popup; the backend mints + stores the CSRF state / PKCE verifier.
|
||||
export default async function startIntegrationOAuth(input: {
|
||||
provider: string;
|
||||
label?: string;
|
||||
}): Promise<IntegrationOAuthStartResponse> {
|
||||
return await Request<IntegrationOAuthStartResponse>({
|
||||
method: "POST",
|
||||
url: "/integrations/oauth/start",
|
||||
data: input,
|
||||
authorization: true,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import getConnection from "@/lib/api/client/app/integrations/getConnection";
|
||||
|
||||
export default function useConnectionDetail(id: string | null) {
|
||||
return useQuery({
|
||||
queryKey: ["integrations", "connection", id],
|
||||
queryFn: () => getConnection(id as string),
|
||||
enabled: !!id,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import createConnectionEvent from "@/lib/api/client/app/integrations/createConnectionEvent";
|
||||
import deleteConnectionEvent from "@/lib/api/client/app/integrations/deleteConnectionEvent";
|
||||
|
||||
export function useCreateConnectionEvent() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: createConnectionEvent,
|
||||
onSuccess: (_data, vars) => {
|
||||
qc.invalidateQueries({ queryKey: ["integrations", "connection", vars.connectionId] });
|
||||
qc.invalidateQueries({ queryKey: ["integrations", "connections"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteConnectionEvent() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: deleteConnectionEvent,
|
||||
onSuccess: (_data, vars) => {
|
||||
qc.invalidateQueries({ queryKey: ["integrations", "connection", vars.connectionId] });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import startIntegrationOAuth from "@/lib/api/client/app/integrations/startIntegrationOAuth";
|
||||
import finishIntegrationOAuth from "@/lib/api/client/app/integrations/finishIntegrationOAuth";
|
||||
import reauthIntegration from "@/lib/api/client/app/integrations/reauthIntegration";
|
||||
|
||||
// useStartIntegrationOAuth returns the provider authorization URL + state.
|
||||
export function useStartIntegrationOAuth() {
|
||||
return useMutation({ mutationFn: startIntegrationOAuth });
|
||||
}
|
||||
|
||||
// useFinishIntegrationOAuth completes the handshake and refreshes connections.
|
||||
export function useFinishIntegrationOAuth() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: finishIntegrationOAuth,
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["integrations", "connections"] });
|
||||
qc.invalidateQueries({ queryKey: ["integrations", "catalog"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// useReauthIntegration starts a fresh handshake for an existing connection.
|
||||
export function useReauthIntegration() {
|
||||
return useMutation({ mutationFn: reauthIntegration });
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
// Mirror of the backend's models/integration.go shapes. Only the fields
|
||||
// the dashboard renders are typed; opaque blobs like display_fields stay
|
||||
// Mirror of the backend's models/integration.go shapes. Only the fields the
|
||||
// dashboard renders are typed; opaque blobs like display_fields / config stay
|
||||
// generic so the UI can dig in without round-trips to the type system.
|
||||
|
||||
export type IntegrationProvider =
|
||||
@@ -16,7 +16,17 @@ export type IntegrationProvider =
|
||||
| "cal_com"
|
||||
| "google_sheets";
|
||||
|
||||
export type IntegrationStatus = "pending" | "connected" | "degraded" | "disconnected";
|
||||
export type IntegrationAuthMethod = "oauth" | "api_key" | "webhook";
|
||||
|
||||
export type IntegrationStatus =
|
||||
| "pending"
|
||||
| "authorizing"
|
||||
| "connected"
|
||||
| "degraded"
|
||||
| "reauth_required"
|
||||
| "disconnected";
|
||||
|
||||
export type IntegrationHealth = "unknown" | "healthy" | "degraded" | "down";
|
||||
|
||||
export type IntegrationCategory =
|
||||
| "crm"
|
||||
@@ -31,10 +41,15 @@ export interface IntegrationCatalogEntry {
|
||||
tagline: string;
|
||||
category: IntegrationCategory;
|
||||
docs_url?: string;
|
||||
auth_method: "oauth" | "api_key" | "webhook";
|
||||
auth_method: IntegrationAuthMethod;
|
||||
badge_color?: string;
|
||||
beta: boolean;
|
||||
webhook_hint?: string;
|
||||
highlights?: string[];
|
||||
scopes?: string[];
|
||||
events?: string[];
|
||||
/** Whether the server has OAuth client credentials wired for this provider. */
|
||||
configured: boolean;
|
||||
}
|
||||
|
||||
export interface IntegrationConnection {
|
||||
@@ -43,7 +58,16 @@ export interface IntegrationConnection {
|
||||
provider: IntegrationProvider;
|
||||
label: string;
|
||||
status: IntegrationStatus;
|
||||
auth_method: IntegrationAuthMethod;
|
||||
display_fields: Record<string, unknown>;
|
||||
connected_by_user_id?: string | null;
|
||||
external_account_id?: string;
|
||||
external_account_name?: string;
|
||||
granted_scopes?: string[];
|
||||
token_expires_at?: string | null;
|
||||
health: IntegrationHealth;
|
||||
health_detail?: string | null;
|
||||
health_checked_at?: string | null;
|
||||
last_synced_at?: string | null;
|
||||
last_error?: string | null;
|
||||
last_error_at?: string | null;
|
||||
@@ -54,6 +78,49 @@ export interface IntegrationConnection {
|
||||
inbound_webhook_url?: string;
|
||||
}
|
||||
|
||||
export type IntegrationAction =
|
||||
| "slack.notify"
|
||||
| "discord.notify"
|
||||
| "hubspot.upsert_contact"
|
||||
| "pipedrive.upsert_person"
|
||||
| "google_sheets.append_row"
|
||||
| "webhook.ping";
|
||||
|
||||
export interface IntegrationEventSubscription {
|
||||
id: string;
|
||||
connection_id: string;
|
||||
organization_id: string;
|
||||
event_type: string;
|
||||
action: IntegrationAction;
|
||||
config: Record<string, unknown>;
|
||||
enabled: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface IntegrationSyncRun {
|
||||
id: string;
|
||||
connection_id: string;
|
||||
organization_id: string;
|
||||
kind: string;
|
||||
status: "running" | "success" | "error";
|
||||
detail: string;
|
||||
records_processed: number;
|
||||
started_at: string;
|
||||
finished_at?: string | null;
|
||||
}
|
||||
|
||||
export interface IntegrationOAuthStartResponse {
|
||||
url: string;
|
||||
state: string;
|
||||
}
|
||||
|
||||
export interface IntegrationConnectionDetail {
|
||||
connection: IntegrationConnection;
|
||||
events: IntegrationEventSubscription[];
|
||||
runs: IntegrationSyncRun[];
|
||||
}
|
||||
|
||||
export interface MeetingBooking {
|
||||
id: string;
|
||||
organization_id: string;
|
||||
@@ -67,3 +134,58 @@ export interface MeetingBooking {
|
||||
campaign_id?: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
// --- presentation helpers (shared by cards + drawers) ----------------------
|
||||
|
||||
export const CATEGORY_LABELS: Record<IntegrationCategory, string> = {
|
||||
crm: "CRM",
|
||||
automation: "Automation",
|
||||
notifications: "Notifications",
|
||||
meetings: "Meetings",
|
||||
data: "Data",
|
||||
};
|
||||
|
||||
export const CATEGORY_ORDER: IntegrationCategory[] = [
|
||||
"crm",
|
||||
"notifications",
|
||||
"automation",
|
||||
"meetings",
|
||||
"data",
|
||||
];
|
||||
|
||||
// Reply-intent classifier buckets, used to filter reply automations
|
||||
// ("only notify me on positive replies"). Mirrors models.ReplyIntentType.
|
||||
export const REPLY_INTENT_OPTIONS: { value: string; label: string }[] = [
|
||||
{ value: "positive", label: "Positive" },
|
||||
{ value: "question", label: "Question" },
|
||||
{ value: "neutral", label: "Neutral" },
|
||||
{ value: "negative", label: "Negative" },
|
||||
{ value: "out_of_office", label: "Out of office" },
|
||||
];
|
||||
|
||||
// Human labels for the Warmbly event vocabulary (subset surfaced as triggers).
|
||||
export const EVENT_LABELS: Record<string, string> = {
|
||||
"campaign.reply_received": "Prospect replies",
|
||||
"campaign.email_bounced": "Email bounces",
|
||||
"campaign.unsubscribed": "Contact unsubscribes",
|
||||
"warmup.health_changed": "Warmup health changes",
|
||||
"deliverability.complaint": "Spam complaint",
|
||||
};
|
||||
|
||||
// Which action a provider performs for an event subscription.
|
||||
export function defaultActionForProvider(provider: IntegrationProvider): IntegrationAction {
|
||||
switch (provider) {
|
||||
case "slack":
|
||||
return "slack.notify";
|
||||
case "discord":
|
||||
return "discord.notify";
|
||||
case "hubspot":
|
||||
return "hubspot.upsert_contact";
|
||||
case "pipedrive":
|
||||
return "pipedrive.upsert_person";
|
||||
case "google_sheets":
|
||||
return "google_sheets.append_row";
|
||||
default:
|
||||
return "webhook.ping";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
// Drives the OAuth connect popup for third-party integrations. Mirrors the
|
||||
// mailbox-onboarding flow: we open the provider authorization URL in a centered
|
||||
// popup; the backend's /integrations/oauth/callback page postMessages the
|
||||
// {code, state} back to this opener; we resolve with them so the caller can
|
||||
// finish the handshake. The window-name carries no secret — the CSRF/PKCE
|
||||
// state lives server-side, keyed by the `state` nonce.
|
||||
|
||||
export interface OAuthPopupResult {
|
||||
code: string;
|
||||
state: string;
|
||||
}
|
||||
|
||||
const POPUP_MESSAGE_SOURCE = "warmbly-integration-oauth";
|
||||
|
||||
export function openOAuthPopup(authUrl: string): Promise<OAuthPopupResult> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const width = 600;
|
||||
const height = 720;
|
||||
const left = window.screenX + Math.max(0, (window.outerWidth - width) / 2);
|
||||
const top = window.screenY + Math.max(0, (window.outerHeight - height) / 2);
|
||||
const popup = window.open(
|
||||
authUrl,
|
||||
"warmbly_oauth",
|
||||
`width=${width},height=${height},left=${left},top=${top},menubar=no,toolbar=no,location=yes`,
|
||||
);
|
||||
if (!popup) {
|
||||
reject(new Error("Popup blocked. Allow popups for this site and try again."));
|
||||
return;
|
||||
}
|
||||
|
||||
let settled = false;
|
||||
const cleanup = () => {
|
||||
window.removeEventListener("message", onMessage);
|
||||
window.clearInterval(closedTimer);
|
||||
};
|
||||
|
||||
const onMessage = (event: MessageEvent) => {
|
||||
const data = event.data as
|
||||
| { source?: string; code?: string; state?: string; error?: string }
|
||||
| undefined;
|
||||
if (!data || data.source !== POPUP_MESSAGE_SOURCE) return;
|
||||
settled = true;
|
||||
cleanup();
|
||||
try {
|
||||
popup.close();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
if (data.error) {
|
||||
reject(new Error(data.error));
|
||||
return;
|
||||
}
|
||||
if (data.code && data.state) {
|
||||
resolve({ code: data.code, state: data.state });
|
||||
return;
|
||||
}
|
||||
reject(new Error("Authorization was cancelled."));
|
||||
};
|
||||
|
||||
window.addEventListener("message", onMessage);
|
||||
|
||||
// Detect a manually-closed popup so the caller's promise doesn't hang.
|
||||
const closedTimer = window.setInterval(() => {
|
||||
if (popup.closed && !settled) {
|
||||
cleanup();
|
||||
reject(new Error("Authorization window was closed before finishing."));
|
||||
}
|
||||
}, 600);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user