diff --git a/server/index.ts b/server/index.ts index b2667aa..6626b38 100644 --- a/server/index.ts +++ b/server/index.ts @@ -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({ setNotionOAuthSyncServer(syncServer); setGoogleOAuthSyncServer(syncServer); setClickUpOAuthSyncServer(syncServer); + setOAuthStatusSyncServer(syncServer); })(); // Ensure generated files directory exists diff --git a/server/oauth/index.ts b/server/oauth/index.ts index 5504893..b91e291 100644 --- a/server/oauth/index.ts +++ b/server/oauth/index.ts @@ -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(connectionsDocId(space)); + // Read rTasks ClickUp connection doc + const clickupDoc = _syncServer.getDoc(clickupConnectionDocId(space)); + + const status: Record = {}; + + // 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 }; diff --git a/shared/components/rstack-space-switcher.ts b/shared/components/rstack-space-switcher.ts index 91850b9..78f93de 100644 --- a/shared/components/rstack-space-switcher.ts +++ b/shared/components/rstack-space-switcher.ts @@ -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 { +
@@ -572,6 +573,10 @@ export class RStackSpaceSwitcher extends HTMLElement {
Loading invitations...
+ +
+
Loading connections...
+
`; @@ -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 = {}; + 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 = ` +
+
+
r
+
rSpace
+
+ +
`; + + 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 = `Coming Soon`; + } else if (connected) { + badge = `Connected`; + if (info?.email) meta = `
${info.email}
`; + else if (info?.workspaceName) meta = `
${info.workspaceName}
`; + else if (info?.teamName) meta = `
${info.teamName}
`; + action = ``; + } else { + badge = `Not connected`; + action = ``; + } + + html += ` +
+
${p.icon}
+
${p.name}
+ ${badge} + ${meta} + ${action} +
`; + } + + html += `
`; + 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, + ) { + 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 += ``; + } + + 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); } } `;