feat(connections): add platform connections dashboard in space settings

New "Connections" tab in space settings with n8n-style visual dashboard
showing platform cards (Google, Notion, ClickUp live + 7 coming soon)
connected via SVG bezier lines to central rSpace hub node. Includes
OAuth connect/disconnect flows and GET /api/oauth/status endpoint.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-31 13:04:02 -07:00
parent eec88ac661
commit 26aa6433be
3 changed files with 317 additions and 7 deletions

View File

@ -91,7 +91,7 @@ import { renderMainLanding, renderSpaceDashboard } from "./landing";
import { syncServer } from "./sync-instance";
import { loadAllDocs } from "./local-first/doc-persistence";
import { backupRouter } from "./local-first/backup-routes";
import { oauthRouter } from "./oauth/index";
import { oauthRouter, setOAuthStatusSyncServer } from "./oauth/index";
import { setNotionOAuthSyncServer } from "./oauth/notion";
import { setGoogleOAuthSyncServer } from "./oauth/google";
import { setClickUpOAuthSyncServer } from "./oauth/clickup";
@ -3678,6 +3678,7 @@ const server = Bun.serve<WSData>({
setNotionOAuthSyncServer(syncServer);
setGoogleOAuthSyncServer(syncServer);
setClickUpOAuthSyncServer(syncServer);
setOAuthStatusSyncServer(syncServer);
})();
// Ensure generated files directory exists

View File

@ -6,6 +6,8 @@
* - Google (user-level, with token refresh)
* - ClickUp (workspace-level, task sync)
*
* Also provides GET /status?space=X to check connection status (no tokens).
*
* Tokens are stored in Automerge docs per space via SyncServer.
*/
@ -13,11 +15,71 @@ import { Hono } from 'hono';
import { notionOAuthRoutes } from './notion';
import { googleOAuthRoutes } from './google';
import { clickupOAuthRoutes } from './clickup';
import { connectionsDocId } from '../../modules/rnotes/schemas';
import { clickupConnectionDocId } from '../../modules/rtasks/schemas';
import type { ConnectionsDoc } from '../../modules/rnotes/schemas';
import type { ClickUpConnectionDoc } from '../../modules/rtasks/schemas';
import type { SyncServer } from '../local-first/sync-server';
const oauthRouter = new Hono();
let _syncServer: SyncServer | null = null;
export function setOAuthStatusSyncServer(ss: SyncServer) {
_syncServer = ss;
}
oauthRouter.route('/notion', notionOAuthRoutes);
oauthRouter.route('/google', googleOAuthRoutes);
oauthRouter.route('/clickup', clickupOAuthRoutes);
// GET /status?space=X — return connection status for all providers (no tokens)
oauthRouter.get('/status', (c) => {
const space = c.req.query('space');
if (!space) return c.json({ error: 'space query param required' }, 400);
if (!_syncServer) return c.json({ error: 'SyncServer not initialized' }, 500);
// Read rNotes connections doc (Google + Notion)
const connDoc = _syncServer.getDoc<ConnectionsDoc>(connectionsDocId(space));
// Read rTasks ClickUp connection doc
const clickupDoc = _syncServer.getDoc<ClickUpConnectionDoc>(clickupConnectionDocId(space));
const status: Record<string, { connected: boolean; connectedAt?: number; email?: string; workspaceName?: string; teamName?: string }> = {};
// Google
if (connDoc?.google) {
status.google = {
connected: true,
connectedAt: connDoc.google.connectedAt,
email: connDoc.google.email,
};
} else {
status.google = { connected: false };
}
// Notion
if (connDoc?.notion) {
status.notion = {
connected: true,
connectedAt: connDoc.notion.connectedAt,
workspaceName: connDoc.notion.workspaceName,
};
} else {
status.notion = { connected: false };
}
// ClickUp
if (clickupDoc?.clickup) {
status.clickup = {
connected: true,
connectedAt: clickupDoc.clickup.connectedAt,
teamName: clickupDoc.clickup.teamName,
};
} else {
status.clickup = { connected: false };
}
return c.json(status);
});
export { oauthRouter };

View File

@ -518,11 +518,11 @@ export class RStackSpaceSwitcher extends HTMLElement {
}
/** Public entry point — both gear icons call this */
openSettingsModal(slug: string, name: string, tab: "settings" | "modules" | "members" | "invitations" = "settings") {
openSettingsModal(slug: string, name: string, tab: "settings" | "modules" | "members" | "invitations" | "connections" = "settings") {
this.#showEditSpaceModal(slug, name, tab);
}
#showEditSpaceModal(slug: string, spaceName: string, initialTab: "settings" | "modules" | "members" | "invitations" = "settings") {
#showEditSpaceModal(slug: string, spaceName: string, initialTab: "settings" | "modules" | "members" | "invitations" | "connections" = "settings") {
if (document.querySelector(".rstack-auth-overlay")) return;
const overlay = document.createElement("div");
@ -537,6 +537,7 @@ export class RStackSpaceSwitcher extends HTMLElement {
<button class="tab ${initialTab === "modules" ? "active" : ""}" data-tab="modules">Modules</button>
<button class="tab ${initialTab === "members" ? "active" : ""}" data-tab="members">Members</button>
<button class="tab ${initialTab === "invitations" ? "active" : ""}" data-tab="invitations">Invitations</button>
<button class="tab ${initialTab === "connections" ? "active" : ""}" data-tab="connections">Connections</button>
</div>
<div class="tab-panel ${initialTab !== "settings" ? "hidden" : ""}" id="panel-settings">
@ -572,6 +573,10 @@ export class RStackSpaceSwitcher extends HTMLElement {
<div class="tab-panel ${initialTab !== "invitations" ? "hidden" : ""}" id="panel-invitations">
<div id="es-invitations-list" class="invitation-list"><div class="loading">Loading invitations...</div></div>
</div>
<div class="tab-panel ${initialTab !== "connections" ? "hidden" : ""}" id="panel-connections">
<div id="es-connections-dashboard" class="conn-dashboard"><div class="loading">Loading connections...</div></div>
</div>
</div>
`;
@ -582,18 +587,24 @@ export class RStackSpaceSwitcher extends HTMLElement {
overlay.addEventListener("click", (e) => { if (e.target === overlay) close(); });
// Tab switching
const editModal = overlay.querySelector(".edit-modal") as HTMLElement;
overlay.querySelectorAll(".tab").forEach((tab) => {
tab.addEventListener("click", () => {
overlay.querySelectorAll(".tab").forEach((t) => t.classList.remove("active"));
overlay.querySelectorAll(".tab-panel").forEach((p) => p.classList.add("hidden"));
tab.classList.add("active");
const panel = overlay.querySelector(`#panel-${(tab as HTMLElement).dataset.tab}`);
const tabName = (tab as HTMLElement).dataset.tab;
const panel = overlay.querySelector(`#panel-${tabName}`);
panel?.classList.remove("hidden");
// Widen modal for connections tab
if (editModal) editModal.style.maxWidth = tabName === "connections" ? "680px" : "";
// Lazy-load content
if ((tab as HTMLElement).dataset.tab === "modules") this.#loadModulesConfig(overlay, slug);
if ((tab as HTMLElement).dataset.tab === "members") this.#loadMembers(overlay, slug);
if ((tab as HTMLElement).dataset.tab === "invitations") this.#loadInvitations(overlay, slug);
if (tabName === "modules") this.#loadModulesConfig(overlay, slug);
if (tabName === "members") this.#loadMembers(overlay, slug);
if (tabName === "invitations") this.#loadInvitations(overlay, slug);
if (tabName === "connections") this.#loadConnections(overlay, slug);
});
});
@ -610,6 +621,10 @@ export class RStackSpaceSwitcher extends HTMLElement {
if (initialTab === "modules") this.#loadModulesConfig(overlay, slug);
if (initialTab === "members") this.#loadMembers(overlay, slug);
if (initialTab === "invitations") this.#loadInvitations(overlay, slug);
if (initialTab === "connections") {
if (editModal) editModal.style.maxWidth = "680px";
this.#loadConnections(overlay, slug);
}
document.body.appendChild(overlay);
}
@ -1076,6 +1091,163 @@ export class RStackSpaceSwitcher extends HTMLElement {
}
}
async #loadConnections(overlay: HTMLElement, slug: string) {
const container = overlay.querySelector("#es-connections-dashboard") as HTMLElement;
if (!container) return;
interface PlatformDef {
id: string;
name: string;
icon: string;
oauthKey?: string; // matches key in status response
comingSoon?: boolean;
}
const platforms: PlatformDef[] = [
{ id: "google", name: "Google", icon: "G", oauthKey: "google" },
{ id: "notion", name: "Notion", icon: "N", oauthKey: "notion" },
{ id: "clickup", name: "ClickUp", icon: "\u2713", oauthKey: "clickup" },
{ id: "telegram", name: "Telegram", icon: "\u2708", comingSoon: true },
{ id: "discord", name: "Discord", icon: "\uD83C\uDFAE", comingSoon: true },
{ id: "github", name: "GitHub", icon: "\uD83D\uDC19", comingSoon: true },
{ id: "slack", name: "Slack", icon: "#", comingSoon: true },
{ id: "twitter", name: "X / Twitter", icon: "X", comingSoon: true },
{ id: "bluesky", name: "Bluesky", icon: "\uD83E\uDD8B", comingSoon: true },
{ id: "linear", name: "Linear", icon: "Lin", comingSoon: true },
];
// Fetch connection status
let statusData: Record<string, { connected: boolean; email?: string; workspaceName?: string; teamName?: string; connectedAt?: number }> = {};
try {
const token = getAccessToken();
const res = await fetch(`/api/oauth/status?space=${encodeURIComponent(slug)}`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
});
if (res.ok) statusData = await res.json();
} catch { /* offline */ }
const anyConnected = platforms.some(p => p.oauthKey && statusData[p.oauthKey]?.connected);
// Build hub + grid
let html = `
<div class="conn-container">
<div class="conn-hub${anyConnected ? " conn-hub--active" : ""}">
<div class="conn-hub-icon">r</div>
<div class="conn-hub-label">rSpace</div>
</div>
<svg class="conn-svg" id="conn-svg"></svg>
<div class="conn-grid">`;
for (const p of platforms) {
const info = p.oauthKey ? statusData[p.oauthKey] : undefined;
const connected = info?.connected ?? false;
const cardClass = p.comingSoon ? "conn-card conn-card--soon" : connected ? "conn-card conn-card--active" : "conn-card";
let badge = "";
let meta = "";
let action = "";
if (p.comingSoon) {
badge = `<span class="conn-badge conn-badge--soon">Coming Soon</span>`;
} else if (connected) {
badge = `<span class="conn-badge conn-badge--connected">Connected</span>`;
if (info?.email) meta = `<div class="conn-meta">${info.email}</div>`;
else if (info?.workspaceName) meta = `<div class="conn-meta">${info.workspaceName}</div>`;
else if (info?.teamName) meta = `<div class="conn-meta">${info.teamName}</div>`;
action = `<button class="conn-btn conn-btn--disconnect" data-provider="${p.oauthKey}">Disconnect</button>`;
} else {
badge = `<span class="conn-badge conn-badge--disconnected">Not connected</span>`;
action = `<button class="conn-btn conn-btn--connect" data-provider="${p.oauthKey}">Connect</button>`;
}
html += `
<div class="${cardClass}" data-platform="${p.id}">
<div class="conn-card-icon">${p.icon}</div>
<div class="conn-card-name">${p.name}</div>
${badge}
${meta}
${action}
</div>`;
}
html += `</div></div>`;
container.innerHTML = html;
// Draw SVG connectors after layout settles
requestAnimationFrame(() => this.#drawConnectorLines(container, platforms, statusData));
// Connect handlers
container.querySelectorAll(".conn-btn--connect").forEach(btn => {
btn.addEventListener("click", () => {
const provider = (btn as HTMLElement).dataset.provider!;
const popup = window.open(`/api/oauth/${provider}/authorize?space=${encodeURIComponent(slug)}`, "_blank", "width=600,height=700");
// Poll for popup close to refresh status
const poll = setInterval(() => {
if (!popup || popup.closed) {
clearInterval(poll);
this.#loadConnections(overlay, slug);
}
}, 1500);
});
});
// Disconnect handlers
container.querySelectorAll(".conn-btn--disconnect").forEach(btn => {
btn.addEventListener("click", async () => {
const provider = (btn as HTMLElement).dataset.provider!;
const el = btn as HTMLButtonElement;
el.disabled = true;
el.textContent = "...";
try {
const token = getAccessToken();
await fetch(`/api/oauth/${provider}/disconnect?space=${encodeURIComponent(slug)}`, {
method: "POST",
headers: token ? { Authorization: `Bearer ${token}` } : {},
});
} catch { /* best-effort */ }
this.#loadConnections(overlay, slug);
});
});
}
#drawConnectorLines(
container: HTMLElement,
platforms: Array<{ id: string; oauthKey?: string; comingSoon?: boolean }>,
statusData: Record<string, { connected: boolean }>,
) {
const svg = container.querySelector("#conn-svg") as SVGElement | null;
const hub = container.querySelector(".conn-hub") as HTMLElement | null;
if (!svg || !hub) return;
const containerRect = container.getBoundingClientRect();
const hubRect = hub.getBoundingClientRect();
const hubCx = hubRect.left + hubRect.width / 2 - containerRect.left;
const hubCy = hubRect.top + hubRect.height / 2 - containerRect.top;
// Size SVG to container
svg.setAttribute("width", String(containerRect.width));
svg.setAttribute("height", String(containerRect.height));
let paths = "";
for (const p of platforms) {
const card = container.querySelector(`[data-platform="${p.id}"]`) as HTMLElement | null;
if (!card) continue;
const cardRect = card.getBoundingClientRect();
const cardCx = cardRect.left + cardRect.width / 2 - containerRect.left;
const cardCy = cardRect.top - containerRect.top; // top edge of card
const connected = p.oauthKey ? statusData[p.oauthKey]?.connected : false;
const lineClass = p.comingSoon ? "conn-line conn-line--soon" : connected ? "conn-line conn-line--active" : "conn-line conn-line--idle";
// Bezier from hub bottom to card top
const midY = hubCy + (cardCy - hubCy) * 0.5;
paths += `<path class="${lineClass}" d="M${hubCx},${hubCy + hubRect.height / 2} C${hubCx},${midY} ${cardCx},${midY} ${cardCx},${cardCy}" />`;
}
svg.innerHTML = paths;
}
async #loadModulesConfig(overlay: HTMLElement, slug: string) {
const container = overlay.querySelector("#es-modules-list") as HTMLElement;
const statusEl = overlay.querySelector("#es-modules-status") as HTMLElement;
@ -2203,6 +2375,81 @@ input:checked + .toggle-slider:before { transform: translateX(16px); }
.add-selected-user { min-height: 0; }
.add-feedback { font-size: 0.78rem; min-height: 16px; }
/* Connections dashboard */
.conn-dashboard { min-height: 200px; }
.conn-container { position: relative; display: flex; flex-direction: column; align-items: center; gap: 1.25rem; padding: 0.5rem 0; }
.conn-hub {
display: flex; flex-direction: column; align-items: center; gap: 4px;
padding: 12px 24px; border-radius: 12px;
background: linear-gradient(135deg, rgba(6,182,212,0.1), rgba(124,58,237,0.1));
border: 1px solid rgba(6,182,212,0.25);
}
.conn-hub--active { animation: connPulse 2s ease-in-out infinite; }
.conn-hub-icon {
width: 36px; height: 36px; border-radius: 10px; display: flex; align-items: center; justify-content: center;
background: linear-gradient(135deg, #06b6d4, #7c3aed); color: white; font-weight: 700; font-size: 1.1rem;
}
.conn-hub-label { font-size: 0.72rem; font-weight: 600; color: var(--rs-text-secondary); text-transform: uppercase; letter-spacing: 0.06em; }
.conn-svg {
position: absolute; top: 0; left: 0; pointer-events: none; overflow: visible;
}
.conn-line { fill: none; stroke-width: 1.5; }
.conn-line--active { stroke: #34d399; stroke-dasharray: 6 3; animation: connDash 1.5s linear infinite; }
.conn-line--idle { stroke: var(--rs-border, #404040); stroke-dasharray: 4 4; opacity: 0.6; }
.conn-line--soon { stroke: var(--rs-border, #404040); stroke-dasharray: 2 4; opacity: 0.25; }
.conn-grid {
display: grid; grid-template-columns: repeat(auto-fill, minmax(130px, 1fr)); gap: 10px; width: 100%;
}
.conn-card {
display: flex; flex-direction: column; align-items: center; gap: 6px;
padding: 14px 8px 10px; border-radius: 10px;
border: 1px solid var(--rs-border, #262626); background: var(--rs-bg-hover, #171717);
text-align: center; transition: border-color 0.2s, box-shadow 0.2s;
}
.conn-card:hover { border-color: var(--rs-text-muted, #525252); }
.conn-card--active { border-color: rgba(52,211,153,0.4); box-shadow: 0 0 8px rgba(52,211,153,0.08); }
.conn-card--soon { opacity: 0.45; pointer-events: none; }
.conn-card-icon {
width: 32px; height: 32px; border-radius: 8px; display: flex; align-items: center; justify-content: center;
background: var(--rs-bg-surface, #0a0a0a); border: 1px solid var(--rs-border, #262626);
font-size: 0.95rem; font-weight: 700; color: var(--rs-text-primary);
}
.conn-card-name { font-size: 0.78rem; font-weight: 600; color: var(--rs-text-primary); }
.conn-badge {
font-size: 0.62rem; font-weight: 600; padding: 2px 7px; border-radius: 6px;
text-transform: uppercase; letter-spacing: 0.04em;
}
.conn-badge--connected { background: rgba(52,211,153,0.15); color: #34d399; }
.conn-badge--disconnected { background: var(--rs-bg-surface, #0a0a0a); color: var(--rs-text-muted, #525252); }
.conn-badge--soon { background: transparent; color: var(--rs-text-muted, #525252); border: 1px solid var(--rs-border, #262626); }
.conn-meta { font-size: 0.68rem; color: var(--rs-text-muted, #525252); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 120px; }
.conn-btn {
padding: 4px 12px; border-radius: 6px; border: none;
font-size: 0.7rem; font-weight: 600; cursor: pointer; transition: opacity 0.15s;
margin-top: 2px;
}
.conn-btn:hover { opacity: 0.85; }
.conn-btn:disabled { opacity: 0.4; cursor: not-allowed; }
.conn-btn--connect { background: linear-gradient(135deg, #06b6d4, #7c3aed); color: white; }
.conn-btn--disconnect { background: rgba(239,68,68,0.12); color: #f87171; }
@keyframes connPulse {
0%, 100% { box-shadow: 0 0 0 0 rgba(6,182,212,0.15); }
50% { box-shadow: 0 0 12px 4px rgba(6,182,212,0.15); }
}
@keyframes connDash { to { stroke-dashoffset: -18; } }
@media (max-width: 480px) {
.conn-svg { display: none; }
.conn-grid { grid-template-columns: repeat(2, 1fr); }
}
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
@keyframes slideUp { from { opacity: 0; transform: translateY(20px); } to { opacity: 1; transform: translateY(0); } }
`;