148 lines
4.4 KiB
TypeScript
148 lines
4.4 KiB
TypeScript
/**
|
|
* ClickUp OAuth2 flow.
|
|
*
|
|
* GET /authorize?space=X → redirect to ClickUp
|
|
* GET /callback → exchange code, store token, redirect back
|
|
* POST /disconnect?space=X → revoke token
|
|
*/
|
|
|
|
import { Hono } from 'hono';
|
|
import * as Automerge from '@automerge/automerge';
|
|
import { clickupConnectionDocId } from '../../modules/rtasks/schemas';
|
|
import type { ClickUpConnectionDoc } from '../../modules/rtasks/schemas';
|
|
import type { SyncServer } from '../local-first/sync-server';
|
|
|
|
const clickupOAuthRoutes = new Hono();
|
|
|
|
const CLICKUP_CLIENT_ID = process.env.CLICKUP_CLIENT_ID || '';
|
|
const CLICKUP_CLIENT_SECRET = process.env.CLICKUP_CLIENT_SECRET || '';
|
|
const CLICKUP_REDIRECT_URI = process.env.CLICKUP_REDIRECT_URI || '';
|
|
|
|
let _syncServer: SyncServer | null = null;
|
|
|
|
export function setClickUpOAuthSyncServer(ss: SyncServer) {
|
|
_syncServer = ss;
|
|
}
|
|
|
|
function ensureConnectionDoc(space: string): ClickUpConnectionDoc {
|
|
if (!_syncServer) throw new Error('SyncServer not initialized');
|
|
const docId = clickupConnectionDocId(space);
|
|
let doc = _syncServer.getDoc<ClickUpConnectionDoc>(docId);
|
|
if (!doc) {
|
|
doc = Automerge.change(Automerge.init<ClickUpConnectionDoc>(), 'init clickup connection', (d) => {
|
|
d.meta = {
|
|
module: 'tasks',
|
|
collection: 'clickup-connection',
|
|
version: 1,
|
|
spaceSlug: space,
|
|
createdAt: Date.now(),
|
|
};
|
|
});
|
|
_syncServer.setDoc(docId, doc);
|
|
}
|
|
return doc;
|
|
}
|
|
|
|
// GET /authorize — redirect to ClickUp OAuth
|
|
clickupOAuthRoutes.get('/authorize', (c) => {
|
|
const space = c.req.query('space');
|
|
if (!space) return c.json({ error: 'space query param required' }, 400);
|
|
if (!CLICKUP_CLIENT_ID) return c.json({ error: 'ClickUp OAuth not configured' }, 500);
|
|
|
|
const state = Buffer.from(JSON.stringify({ space })).toString('base64url');
|
|
const url = `https://app.clickup.com/api?client_id=${CLICKUP_CLIENT_ID}&redirect_uri=${encodeURIComponent(CLICKUP_REDIRECT_URI)}&state=${state}`;
|
|
|
|
return c.redirect(url);
|
|
});
|
|
|
|
// GET /callback — exchange code for token
|
|
clickupOAuthRoutes.get('/callback', async (c) => {
|
|
const code = c.req.query('code');
|
|
const stateParam = c.req.query('state');
|
|
|
|
if (!code || !stateParam) return c.json({ error: 'Missing code or state' }, 400);
|
|
|
|
let state: { space: string };
|
|
try {
|
|
state = JSON.parse(Buffer.from(stateParam, 'base64url').toString());
|
|
} catch {
|
|
return c.json({ error: 'Invalid state parameter' }, 400);
|
|
}
|
|
|
|
// Exchange code for access token
|
|
const tokenRes = await fetch('https://api.clickup.com/api/v2/oauth/token', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
client_id: CLICKUP_CLIENT_ID,
|
|
client_secret: CLICKUP_CLIENT_SECRET,
|
|
code,
|
|
}),
|
|
});
|
|
|
|
if (!tokenRes.ok) {
|
|
const err = await tokenRes.text();
|
|
return c.json({ error: `Token exchange failed: ${err}` }, 502);
|
|
}
|
|
|
|
const tokenData = await tokenRes.json() as any;
|
|
const accessToken = tokenData.access_token;
|
|
|
|
// Fetch workspace info to store team ID
|
|
const teamsRes = await fetch('https://api.clickup.com/api/v2/team', {
|
|
headers: { 'Authorization': accessToken },
|
|
});
|
|
|
|
let teamId = '';
|
|
let teamName = 'ClickUp Workspace';
|
|
if (teamsRes.ok) {
|
|
const teamsData = await teamsRes.json() as any;
|
|
if (teamsData.teams?.length > 0) {
|
|
teamId = teamsData.teams[0].id;
|
|
teamName = teamsData.teams[0].name;
|
|
}
|
|
}
|
|
|
|
// Generate webhook secret
|
|
const secretBuf = new Uint8Array(32);
|
|
crypto.getRandomValues(secretBuf);
|
|
const webhookSecret = Array.from(secretBuf).map(b => b.toString(16).padStart(2, '0')).join('');
|
|
|
|
// Store token in Automerge connections doc
|
|
ensureConnectionDoc(state.space);
|
|
const docId = clickupConnectionDocId(state.space);
|
|
|
|
_syncServer!.changeDoc<ClickUpConnectionDoc>(docId, 'Connect ClickUp', (d) => {
|
|
d.clickup = {
|
|
accessToken,
|
|
teamId,
|
|
teamName,
|
|
connectedAt: Date.now(),
|
|
webhookSecret,
|
|
};
|
|
});
|
|
|
|
// Redirect back to rTasks
|
|
const redirectUrl = `/${state.space}/rtasks?connected=clickup`;
|
|
return c.redirect(redirectUrl);
|
|
});
|
|
|
|
// POST /disconnect — remove token
|
|
clickupOAuthRoutes.post('/disconnect', async (c) => {
|
|
const space = c.req.query('space');
|
|
if (!space) return c.json({ error: 'space query param required' }, 400);
|
|
|
|
const docId = clickupConnectionDocId(space);
|
|
const doc = _syncServer?.getDoc<ClickUpConnectionDoc>(docId);
|
|
|
|
if (doc?.clickup) {
|
|
_syncServer!.changeDoc<ClickUpConnectionDoc>(docId, 'Disconnect ClickUp', (d) => {
|
|
delete d.clickup;
|
|
});
|
|
}
|
|
|
|
return c.json({ ok: true });
|
|
});
|
|
|
|
export { clickupOAuthRoutes };
|