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:
parent
eec88ac661
commit
26aa6433be
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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); } }
|
||||
`;
|
||||
|
|
|
|||
Loading…
Reference in New Issue