Files
openworker/surfaces/gui/src/components/ManageTabs.tsx
T
Rohit C Prasad 2a2fffd280 Pause Google one-click connect pending CASA verification
Gmail/Calendar/Drive show a disabled Coming-soon button; the server refuses the flow too.
Manual token connect and already-connected accounts are untouched.
2026-07-23 07:43:24 -07:00

891 lines
32 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useState } from "react";
import {
addMcpServer,
allowUser,
connectConnector,
connectManaged,
connectMcpBacked,
connectMcp,
deleteMcpServer,
disallowUser,
getMcpServers,
getMcpTools,
signoutMcp,
getSettings,
getSubscriptions,
removeModel,
resolveUnauthorized,
unsubscribeChannel,
patchMcpServer,
reloadMcp,
setDefaultModel,
updateConnectorTools,
type CloudStatus,
type Connector,
type Subscription,
type McpServer,
type ModelSettings,
type ProviderInfo,
} from "../api";
import { CloudSignInInline, CloudStatusPending } from "./connectors/CloudSignIn";
import { ModelChecklist } from "./ModelChecklist";
import { ProviderCards, ProviderForm, useProviderSetup } from "../providers/ProviderSetup";
import { Toggle } from "./Toggle";
// "2h ago"-style label for the providers' Last-used line (null when never used).
const relTime = (epoch?: number | null): string | null => {
if (!epoch) return null;
const secs = Math.max(0, Math.floor(Date.now() / 1000 - epoch));
if (secs < 90) return "just now";
const mins = Math.floor(secs / 60);
if (mins < 60) return `${mins}m ago`;
const hrs = Math.floor(mins / 60);
if (hrs < 48) return `${hrs}h ago`;
return `${Math.floor(hrs / 24)}d ago`;
};
// Shared tab bodies for the Settings and Integrations pages (the old top-tab ManageModal was retired
// when Settings/Activity became full-page surfaces): ModelsTab → Settings ▸ Models; ConnectorsTab +
// McpTab → Integrations ▸ Connectors / MCP servers.
const SEC_H = "text-[11px] uppercase tracking-[0.05em] text-faint font-semibold";
const CARD = "rounded-xl2 border border-line bg-panel";
const BTN_BORDERED =
"text-[12.5px] px-3 py-1.5 rounded-lg border border-line bg-paper hover:border-lineStrong shrink-0";
const BTN_ACCENT = "text-[12.5px] px-3 py-1.5 rounded-lg bg-accent text-white shrink-0 disabled:opacity-50";
const BTN_DANGER = "text-[12.5px] text-danger/80 hover:text-danger shrink-0";
/** Two-letter initials for a chip/avatar (first+last word, else first two chars). */
function initials(name: string): string {
const parts = name.trim().split(/\s+/).filter(Boolean);
if (parts.length === 0) return "?";
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
}
const EXAMPLE = `{
"filesystem": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/path/to/dir"],
"enabled": true
}
}`;
// -- Configure Models tab (UX-021: the shared provider gallery + key form) ----
// Settings ▸ Models reuses onboarding §39's ProviderCards/ProviderForm so the two
// surfaces can't drift. Settings-only extras: per-card "used Nh ago", a "Remove
// key…" affordance, the global composer-picker card (gallery view), and the
// per-provider ModelChecklist / read-only model preview (form view).
export function ModelsTab() {
const [settings, setSettings] = useState<ModelSettings | null>(null);
const refreshSettings = () => getSettings().then(setSettings).catch(() => setSettings(null));
const ps = useProviderSetup({ onSaved: refreshSettings });
useEffect(() => {
refreshSettings();
}, []);
if (!settings) return <div className="text-[13px] text-muted">Loading</div>;
const info = ps.info;
const knownNames = ps.providers.map((p) => p.name);
if (ps.sel === null) {
return (
<div>
<ProviderCards ps={ps} tp="set" gridClass="grid grid-cols-2 xl:grid-cols-3 gap-2.5" lastUsed />
<ComposerPickerCard settings={settings} providers={ps.providers} onChanged={refreshSettings} />
</div>
);
}
return (
<div>
<ProviderForm
ps={ps}
tp="set"
footer={
ps.credentialed ? (
<button
className="text-[12.5px] text-danger/80 hover:text-danger hover:underline underline-offset-2"
data-testid="set-remove-key"
onClick={() => {
if (window.confirm(`Remove the ${info?.title} key from this computer?`)) ps.removeKey();
}}
>
Remove key
</button>
) : null
}
/>
{ps.sel === "openai" && settings.source === "env" && (
<p className="text-[12px] text-muted mt-3 leading-relaxed">
A key is set via <code>OPENAI_API_KEY</code> in this server's environment. You can override
it above; the stored key is used only when the environment variable is absent.
</p>
)}
{info?.configured ? (
<div className="mt-6">
<div className={SEC_H + " mb-1.5"}>Models</div>
<p className="text-[12px] text-muted mb-2.5 leading-relaxed">
Ticked models show in the composer's picker; the black badge marks the default for new
sessions.
</p>
<ModelChecklist
provider={ps.sel}
knownProviders={knownNames}
suggested={info?.suggested_models || []}
curated={settings.models}
defaultModel={settings.model}
labels={settings.model_labels}
onChanged={(next) => setSettings((s) => (s ? { ...s, models: next.models, model: next.model } : s))}
/>
</div>
) : (
// Unconfigured providers still show their curated models as a read-only preview — what a
// key unlocks is part of deciding to get one at all (owner ask, 2026-07-04).
(info?.suggested_models?.length || 0) > 0 && (
<div className="mt-6" data-testid="model-preview">
<div className={SEC_H + " mb-1.5"}>Included models</div>
<p className="text-[12px] text-muted mb-2.5 leading-relaxed">
Curated, agent-capable models this provider serves add your key above to enable them.
</p>
<div className="space-y-1">
{(info?.suggested_models || []).map((m) => {
const full = ps.sel === "openai" ? m : `${ps.sel}:${m}`;
return (
<div
key={m}
className="px-2.5 py-1.5 rounded-lg border border-line bg-paper text-[13px] text-muted"
title={full}
>
{settings.model_labels?.[full] || m}
</div>
);
})}
</div>
</div>
)
)}
</div>
);
}
// The gallery view's "In the composer's picker" card: every curated model across providers,
// with its provider tag. Unticking removes it from the picker; adding happens from a
// provider's card (the ModelChecklist there has the suggested list + free-type add).
function ComposerPickerCard({
settings,
providers,
onChanged,
}: {
settings: ModelSettings;
providers: ProviderInfo[];
onChanged: () => void;
}) {
const names = providers.map((p) => p.name);
const provOf = (id: string) => {
const i = id.indexOf(":");
return i > 0 && names.includes(id.slice(0, i)) ? id.slice(0, i) : "openai";
};
const tag = (id: string) => {
const p = providers.find((x) => x.name === provOf(id));
return (p?.title || provOf(id)).split(" (")[0];
};
return (
<div className="mt-6" data-testid="composer-picker">
<div className={SEC_H + " mb-1.5"}>In the composer's picker</div>
<p className="text-[12px] text-muted mb-2.5 leading-relaxed">
The models offered when starting a session; the black badge marks the default. Add more
from a provider's card above.
</p>
<div className="mlist">
{settings.models.map((id) => {
const isDefault = id === settings.model;
return (
<div className="mlist-row" key={id}>
<label className="mlist-main">
<input
type="checkbox"
checked
disabled={isDefault}
title={isDefault ? "The default model is always shown — make another model default first" : "Remove from the picker"}
onChange={() => removeModel(id).then((r) => r.ok && onChanged())}
/>
<span className="mlist-name" title={id}>
{settings.model_labels?.[id] || id}
</span>
</label>
<span className="text-[11px] text-faint mr-2 shrink-0">{tag(id)}</span>
{isDefault ? (
<span className="mlist-default">default</span>
) : (
<button className="mlist-make" onClick={() => setDefaultModel(id).then(() => onChanged())}>
Make default
</button>
)}
</div>
);
})}
</div>
</div>
);
}
// Curated OAuth quick-adds: remote MCP servers with browser sign-in (OAuth 2.1 + DCR) —
// no keys to paste, tokens stay in the local secret store. First: Granola.
const MCP_PRESETS: { name: string; label: string; blurb: string; config: Record<string, any> }[] = [
{
name: "granola",
label: "Granola",
blurb: "Meeting notes & transcripts — sign in with your Granola account.",
config: { type: "http", url: "https://mcp.granola.ai/mcp", auth: "oauth" },
},
];
export function McpTab() {
const [servers, setServers] = useState<McpServer[]>([]);
const [adding, setAdding] = useState(false);
const [error, setError] = useState<string | null>(null);
const refresh = () => getMcpServers().then(setServers).catch(() => setServers([]));
useEffect(() => {
refresh();
}, []);
// While a browser sign-in is in flight, poll so the row flips to connected (or
// surfaces the error) without the user having to touch anything.
const authorizing = servers.some((s) => s.status === "authorizing");
useEffect(() => {
if (!authorizing) return;
const t = window.setInterval(refresh, 2000);
return () => window.clearInterval(t);
}, [authorizing]);
const toggle = async (s: McpServer) => {
await patchMcpServer(s.name, { enabled: !s.enabled });
refresh();
};
const remove = async (s: McpServer) => {
await deleteMcpServer(s.name);
refresh();
};
return (
<div className="space-y-3">
<p className="text-[12.5px] text-muted leading-relaxed">
External tool servers (stdio or HTTP), shared across all agents. Enabled servers' tools are
permission-gated. Changes apply to new sessions —{" "}
<button
className="text-accent font-medium hover:underline"
onClick={() => reloadMcp().then(refresh)}
>
reload now
</button>
.
</p>
{servers.length === 0 && !adding ? (
<div className={CARD + " p-4 text-[13px] text-muted"}>
No MCP servers configured.{" "}
<button className="text-accent font-medium" onClick={() => setAdding(true)}>
Add a server
</button>
</div>
) : (
<div className="space-y-2">
{servers.map((s) => (
<McpRow
key={s.name}
server={s}
onToggle={() => toggle(s)}
onRemove={() => remove(s)}
onRefresh={refresh}
/>
))}
</div>
)}
{/* One-click OAuth presets not yet configured. */}
{MCP_PRESETS.filter((p) => !servers.some((s) => s.name === p.name)).map((p) => (
<div key={p.name} className={CARD + " p-3.5 flex items-center gap-3"} data-testid={`mcp-preset-${p.name}`}>
<div className="flex-1 min-w-0">
<div className="text-[14px] font-medium">{p.label}</div>
<div className="text-[11.5px] text-faint">{p.blurb}</div>
</div>
<button
className={BTN_ACCENT}
onClick={async () => {
await addMcpServer(p.name, p.config);
await connectMcp(p.name); // opens the browser sign-in right away
refresh();
}}
>
Connect
</button>
</div>
))}
{adding ? (
<AddForm
onCancel={() => {
setAdding(false);
setError(null);
}}
onError={setError}
onAdded={() => {
setAdding(false);
setError(null);
refresh();
}}
/>
) : servers.length > 0 ? (
<button className={BTN_ACCENT} onClick={() => setAdding(true)}>
+ Add server
</button>
) : null}
{error && <div className="text-[12.5px] text-danger">{error}</div>}
</div>
);
}
function McpRow({
server,
onToggle,
onRemove,
onRefresh,
}: {
server: McpServer;
onToggle: () => void;
onRemove: () => void;
onRefresh: () => void;
}) {
const [tools, setTools] = useState<{ name: string; description: string }[] | null>(null);
const [busy, setBusy] = useState(false);
const [toolErr, setToolErr] = useState<string | null>(null);
const isOauth = server.auth === "oauth";
const authorizing = server.status === "authorizing";
const signIn = async () => {
await connectMcp(server.name); // browser opens; the tab's poll flips the status
onRefresh();
};
const signOut = async () => {
await signoutMcp(server.name);
onRefresh();
};
const loadTools = async () => {
if (tools) {
setTools(null);
return;
}
setBusy(true);
setToolErr(null);
const res = await getMcpTools(server.name);
setBusy(false);
if (res.ok) setTools(res.tools);
else setToolErr(res.error || "failed to connect");
};
return (
<div className={CARD + " p-3.5"}>
<div className="flex items-center gap-3">
<Toggle checked={server.enabled} onChange={onToggle} title="Enable this server" />
<div className="flex-1 min-w-0">
<div className="text-[14px] font-medium">{server.name}</div>
<div className="text-[11.5px] text-faint">
{server.transport} · {authorizing ? "signing in…" : server.status.replace("_", " ")}
{server.tool_count != null ? ` · ${server.tool_count} tools` : ""}
{server.requires_approval ? " · asks" : ""}
{isOauth ? " · oauth" : ""}
</div>
</div>
{isOauth &&
(server.status === "needs_auth" ? (
<button className={BTN_ACCENT} onClick={signIn} data-testid={`mcp-signin-${server.name}`}>
Sign in
</button>
) : authorizing ? (
<span className="text-[12px] text-muted shrink-0">waiting for browser</span>
) : server.status === "connected" ? (
<button
className="text-[12px] text-muted hover:text-ink shrink-0"
onClick={signOut}
data-testid={`mcp-signout-${server.name}`}
>
sign out
</button>
) : null)}
<button
className="text-[12px] text-muted hover:text-ink shrink-0"
onClick={loadTools}
disabled={busy}
>
{busy ? "…" : tools ? "hide tools" : "tools"}
</button>
<button className={BTN_DANGER} onClick={onRemove}>
remove
</button>
</div>
{server.last_error && server.status !== "connected" && (
<div className="text-[12.5px] text-danger mt-1.5">{server.last_error}</div>
)}
{toolErr && <div className="text-[12.5px] text-danger mt-1.5">{toolErr}</div>}
{tools && (
<div className="mt-2.5 pt-2.5 border-t border-line flex flex-wrap gap-1.5">
{tools.length === 0 && <div className="text-[12px] text-faint">No tools.</div>}
{tools.map((t) => (
<span
key={t.name}
title={t.description}
className="font-mono text-[11.5px] px-1.5 py-0.5 rounded-md bg-paper border border-line"
>
{t.name}
</span>
))}
</div>
)}
</div>
);
}
function AddForm({
onCancel,
onAdded,
onError,
}: {
onCancel: () => void;
onAdded: () => void;
onError: (e: string | null) => void;
}) {
const [text, setText] = useState(EXAMPLE);
const save = async () => {
onError(null);
let parsed: any;
try {
parsed = JSON.parse(text);
} catch (e: any) {
onError("Invalid JSON: " + e.message);
return;
}
// Accept either {mcpServers:{...}}, {name:{...}}, or a single bare config.
const map = parsed.mcpServers || parsed;
const entries =
map && typeof map === "object" && !map.command && !map.url
? Object.entries(map)
: null;
if (!entries || entries.length === 0) {
onError('Paste a `{ "<name>": { … } }` object (or a full mcpServers block).');
return;
}
for (const [name, config] of entries) {
await addMcpServer(name, config as Record<string, any>);
}
onAdded();
};
return (
<div className="space-y-2">
<div className="text-[12.5px] text-muted">Paste server JSON (name config):</div>
<textarea
value={text}
onChange={(e) => setText(e.target.value)}
spellCheck={false}
rows={9}
className="w-full font-mono text-[12px] px-3 py-2.5 rounded-lg border border-line bg-paper text-ink outline-none focus:border-accent resize-y"
/>
<div className="flex items-center gap-3">
<button className={BTN_ACCENT} onClick={save}>
Add
</button>
<button className="text-[12.5px] text-muted hover:text-ink" onClick={onCancel}>
cancel
</button>
</div>
</div>
);
}
// -- Connectors ---------------------------------------------------------------
// The Connectors tab body moved to connectors/ConnectorsSection.tsx (UX-DECISIONS
// §21: connected-first list + per-connector detail subpages). This file keeps the
// shared building blocks the detail pages reuse: ConnectSetup, ConnectorTools, and
// the two-way blocks (Allowlist/Unauthorized/ListeningSessions).
// Parked messages from senders not on the allow-list (§19). The gateway keeps what they said
// instead of dropping it, so first contact is one step: Allow & deliver replays the original
// message through the normal inbound path — no "message the bot again".
// With `teamId` (the Slack-workspaces page) only that workspace's parked messages show;
// resolving routes the allow to the right workspace server-side (the item carries its team).
export function UnauthorizedBlock({
c,
onChanged,
teamId,
}: {
c: Connector;
onChanged: () => void;
teamId?: string;
}) {
const items = (c.unauthorized ?? []).filter(
(m) => teamId === undefined || m.team_id === teamId,
);
if (items.length === 0) return null;
const act = async (id: string, action: "dismiss" | "allow" | "allow_deliver") => {
await resolveUnauthorized(c.name, id, action);
onChanged();
};
return (
<div
className="border-t border-line px-3.5 py-3"
data-testid={teamId ? `unauthorized-${c.name}-${teamId}` : `unauthorized-${c.name}`}
>
<div className={SEC_H + " mb-2"}>
Messages from senders you haven't allowed · {items.length}
</div>
<div className="space-y-2">
{items.map((m) => (
<div key={m.id} className="rounded-xl border border-line bg-paper p-2.5">
<div className="flex items-center gap-2 text-[12px] text-muted">
<span className="font-medium text-ink">{m.user_name || m.user_id}</span>
<span>in {m.chat_name || m.chat_id}</span>
<span className="ml-auto shrink-0">{relTime(m.ts) || ""}</span>
</div>
<div className="text-[12.5px] mt-1 break-words">{m.text}</div>
<div className="flex items-center gap-1.5 mt-2">
<button
className="text-[11.5px] px-2 py-1 rounded-md bg-accent text-white"
data-testid={`parked-allow-deliver-${m.id}`}
title="Add the sender to the allow-list and deliver this message now"
onClick={() => act(m.id, "allow_deliver")}
>
Allow & deliver
</button>
<button
className={BTN_BORDERED}
data-testid={`parked-allow-${m.id}`}
title="Add the sender to the allow-list; this message is discarded"
onClick={() => act(m.id, "allow")}
>
Allow only
</button>
<button
className="text-[11.5px] px-2 py-1 rounded-md text-faint hover:text-danger"
data-testid={`parked-dismiss-${m.id}`}
title="Throw this message away"
onClick={() => act(m.id, "dismiss")}
>
Dismiss
</button>
</div>
</div>
))}
</div>
</div>
);
}
// Which sessions listen to this connector's channels the per-connector cut of the global
// Channel-subscriptions table (Integrations ▸ Messaging routing). Subscribing happens from a
// session's Sources ▸ Channels panel; here the owner can see and revoke.
export function ListeningSessionsBlock({ c }: { c: Connector }) {
const [subs, setSubs] = useState<Subscription[] | null>(null);
const load = () => getSubscriptions().then(setSubs).catch(() => setSubs([]));
useEffect(() => {
load();
}, [c.name]);
const platformOf = (channel: string) =>
channel.includes(":") ? channel.split(":")[0] : "slack";
const mine = (subs ?? []).filter((s) => platformOf(s.channel) === c.name);
return (
<div className="border-t border-line px-3.5 py-3" data-testid={`listening-${c.name}`}>
<div className={SEC_H + " mb-2"}>Sessions listening to {c.title} channels · {mine.length}</div>
{mine.length === 0 ? (
<div className="text-[12px] text-faint">
None yet open a session's Sources ▸ Channels to subscribe it to a channel.
</div>
) : (
<div className="space-y-1.5">
{mine.map((s) => (
<div className="flex items-center gap-2 text-[12.5px]" key={s.session_id + s.channel}>
<span className="min-w-0 truncate" title={s.session_id}>
{s.session_title || s.session_id}
{s.agent ? <span className="text-faint"> · {s.agent}</span> : null}
</span>
<span className="text-muted shrink-0" title={s.channel}>
← {s.channel_name ? `#${s.channel_name}` : s.channel}
</span>
<button
className="ml-auto text-faint hover:text-danger shrink-0"
title="Unsubscribe this session"
onClick={async () => {
await unsubscribeChannel(s.session_id, s.channel);
load();
}}
>
×
</button>
</div>
))}
</div>
)}
</div>
);
}
// Who may message this two-way bot. Recent senders surface here once they DM/mention the bot, so you
// can Allow them; allowed users are chips you can remove. (Was orphaned in the super-agent view.)
// With `teamId` (the Slack-workspaces page) the list is that WORKSPACE's ids are
// workspace-scoped, so allow/remove target `slack:team:<id>` and recents filter to the team.
export function AllowlistBlock({
c,
onChanged,
teamId,
allowed,
allowedNames,
}: {
c: Connector;
onChanged: () => void;
teamId?: string;
allowed?: string[];
allowedNames?: Record<string, string | null>;
}) {
const allowedUsers = allowed ?? c.allowed_users;
const names = allowedNames ?? c.allowed_user_names;
const recent = (c.recent ?? []).filter(
(r) => teamId === undefined || r.team_id === teamId,
);
const unknownRecent = recent.filter((r) => !r.authorized);
return (
<div className="border-t border-line px-3.5 py-3 grid grid-cols-2 gap-5">
<div>
<div className={SEC_H + " mb-2"}>Allowed to message</div>
<div className="flex flex-wrap gap-1.5">
{allowedUsers.length === 0 && (
<span className="text-[12px] text-faint">nobody yet Allow a recent sender </span>
)}
{allowedUsers.map((u) => (
<span
key={u}
className="inline-flex items-center gap-1.5 pl-2 pr-1 py-1 rounded-full bg-paper border border-line text-[12px]"
title={`id ${u}`}
>
<span className="w-4 h-4 rounded-full bg-accentSoft text-accent grid place-items-center text-[9px] font-bold">
{initials(names?.[u] || u)}
</span>
{names?.[u] || u}
<button
className="w-4 h-4 grid place-items-center text-faint hover:text-danger"
title="remove"
onClick={async () => {
await disallowUser(c.name, u, teamId);
onChanged();
}}
>
×
</button>
</span>
))}
</div>
</div>
<div>
<div className={SEC_H + " mb-2"}>Recent senders</div>
{unknownRecent.length === 0 ? (
<div className="text-[12px] text-faint">None yet. Message the bot once and it'll show here.</div>
) : (
<div className="space-y-1.5">
{unknownRecent.map((r) => (
<div className="flex items-center gap-2 text-[12.5px]" key={r.user_id}>
<span className="w-5 h-5 rounded-full bg-paper border border-line grid place-items-center text-[9px] font-bold text-muted shrink-0">
{initials(r.user_name || "?")}
</span>
<span className="min-w-0 truncate" title={`id ${r.user_id}`}>
{r.user_name || "unknown"} <span className="text-faint">· {r.chat_type}</span>
</span>
<button
className="ml-auto text-[11.5px] px-2 py-0.5 rounded-md bg-accent text-white shrink-0"
onClick={async () => {
await allowUser(c.name, r.user_id, teamId);
onChanged();
}}
>
Allow
</button>
</div>
))}
</div>
)}
</div>
</div>
);
}
export function ConnectorTools({ c, onChanged }: { c: Connector; onChanged: () => void }) {
const toggle = async (toolName: string, enabled: boolean) => {
await updateConnectorTools(c.name, { [toolName]: enabled });
onChanged();
};
if (!c.tools?.length)
return (
<div className="border-t border-line px-3.5 py-3 text-[12.5px] text-muted">
No tools for this connector yet.
</div>
);
return (
<div className="border-t border-line px-3.5 py-3">
<div className={SEC_H + " mb-2"}>Tools exposed to OpenWorker</div>
<div className="space-y-1.5">
{c.tools.map((tool) => (
<label
className="flex items-start gap-2.5 p-2 rounded-lg border border-line bg-paper"
key={tool.name}
>
<input
type="checkbox"
className="mt-0.5 shrink-0"
checked={tool.enabled}
onChange={(e) => toggle(tool.name, e.target.checked)}
/>
<span className="min-w-0">
<span className="block text-[13px]">{tool.label}</span>
<span className="block text-[11.5px] text-faint">
{tool.name} · {tool.kind} · asks approval
</span>
<span className="block text-[11.5px] text-faint">{tool.description}</span>
</span>
</label>
))}
</div>
</div>
);
}
// Exported: also hosted inside the SourcesDrawer's connect-in-context child panel, so a
// recommended connector can be connected without leaving the session (owner ask, 2026-07-03).
export function ConnectSetup({
c,
cloud,
onConnected,
manualOnly = false,
}: {
c: Connector;
cloud: CloudStatus | null;
onConnected: () => void;
// The add-modal's Manual pane: the one-click button lives on the sibling
// pill, so don't render the managed block again here.
manualOnly?: boolean;
}) {
const [values, setValues] = useState<Record<string, string>>({});
const [busy, setBusy] = useState(false);
const [waiting, setWaiting] = useState(false); // managed flow: browser is open
const [error, setError] = useState<string | null>(null);
const submit = async () => {
setBusy(true);
setError(null);
const res = await connectConnector(c.name, values);
setBusy(false);
if (res.ok) onConnected();
else setError(res.error || "could not connect");
};
const oneClick = async () => {
setError(null);
const res = await connectManaged(c.name);
// Completion arrives via the tab's poll: the broker form-POSTs the profile
// to the sidecar, the connector flips to connected, this card closes itself.
if (res.ok) setWaiting(true);
else setError(res.error || "could not start managed connect");
};
const mcpOneClick = async () => {
setError(null);
const res = await connectMcpBacked(c.name);
// Completion likewise arrives via the poll — the sidecar flips the connector
// to connected once the local OAuth flow lands.
if (res.ok) setWaiting(true);
else setError(res.error || "could not start the connect");
};
return (
<div className="border-t border-line px-3.5 py-3 space-y-3">
{c.mcp && !manualOnly && (
/* MCP-backed one-click needs no cloud sign-in — the OAuth flow is local. */
<div className="space-y-2" data-testid="mcp-connect">
<button className={BTN_ACCENT} onClick={mcpOneClick} disabled={waiting}>
{waiting ? "Check your browser…" : `Connect ${c.title} with one click`}
</button>
{c.fields.length > 0 && (
<div className="text-[11.5px] text-faint">or connect manually:</div>
)}
</div>
)}
{c.managed && !c.mcp && !manualOnly && (
<div className="space-y-2" data-testid="managed-connect">
{c.managed_paused ? (
// One-click temporarily off (e.g. Google pending CASA verification):
// a visibly-parked button, and the manual path below stays fully live.
<>
<button className={BTN_ACCENT + " opacity-50"} disabled data-testid="managed-coming-soon">
{`Connect ${c.title} with one click`}
<span className="ml-2 text-[11px] font-medium px-1.5 py-0.5 rounded-full bg-white/25">
Coming soon
</span>
</button>
<div className="text-[11.5px] text-faint">
One-click sign-in is coming soon connect manually below for now:
</div>
</>
) : cloud?.signed_in ? (
<button className={BTN_ACCENT} onClick={oneClick} disabled={waiting}>
{waiting ? "Check your browser…" : `Connect ${c.title} with one click`}
</button>
) : cloud ? (
<CloudSignInInline
blurb={`Sign-in unlocks the one-click ${c.title} connect — or connect manually below.`}
/>
) : (
// Status unknown (fetch pending/failed): never show the sign-in ask to a
// possibly-signed-in user (FB-013); the host keeps polling.
<CloudStatusPending />
)}
{!c.managed_paused && cloud?.signed_in && (
<div className="text-[11.5px] text-faint">or connect manually:</div>
)}
</div>
)}
{c.instructions.length > 0 && (
<ol className="list-decimal pl-4 text-[12.5px] text-muted leading-relaxed space-y-1">
{c.instructions.map((step, i) => (
<li key={i}>{step}</li>
))}
</ol>
)}
{c.fields.map((f) => (
<label className="conn-field" key={f.key}>
<span className="conn-field-label">
{f.label}
{!f.required && <em> (optional)</em>}
</span>
<input
type={f.secret ? "password" : "text"}
placeholder={f.placeholder}
value={values[f.key] || ""}
spellCheck={false}
onChange={(e) => setValues({ ...values, [f.key]: e.target.value })}
/>
{f.help && <span className="conn-field-help">{f.help}</span>}
</label>
))}
<div>
<button className={BTN_ACCENT} onClick={submit} disabled={busy}>
{busy ? "Validating…" : "Connect"}
</button>
</div>
{error && <div className="text-[12.5px] text-danger">{error}</div>}
</div>
);
}