@taskforcehq/taskforce 0.3.318 → 0.3.320

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -579,14 +579,44 @@ function TaskforceCoreWithRouter({ config = {}, initialTaskId, onTaskCountChange
579
579
  persistWorkspaceSyncStatePatch,
580
580
  refreshSetupContext
581
581
  ]);
582
- const nextPath = useMemo(() => {
582
+ const nextTarget = useMemo(() => {
583
583
  const raw = new URLSearchParams(location.search).get('next') || '/';
584
- if (!raw.startsWith('/'))
585
- return '/';
586
- if (raw === '/login')
584
+ if (raw.startsWith('/')) {
585
+ if (raw === '/login')
586
+ return '/';
587
+ return raw;
588
+ }
589
+ try {
590
+ const parsed = new URL(raw);
591
+ const host = parsed.hostname.toLowerCase();
592
+ const isLocal = host === 'localhost' || host === '127.0.0.1' || host === '::1';
593
+ const isTaskforceHost = host === 'taskforcehq.ai' || host.endsWith('.taskforcehq.ai');
594
+ if ((parsed.protocol === 'http:' || parsed.protocol === 'https:') && (isLocal || isTaskforceHost)) {
595
+ return parsed.toString();
596
+ }
597
+ }
598
+ catch {
587
599
  return '/';
588
- return raw;
600
+ }
601
+ return '/';
589
602
  }, [location.search]);
603
+ const nextPath = useMemo(() => nextTarget.startsWith('/') ? nextTarget : '/', [nextTarget]);
604
+ const navigateToPostAuthTarget = useCallback((target, options) => {
605
+ if (!target)
606
+ return;
607
+ if (target.startsWith('/')) {
608
+ navigate(target, { replace: options?.replace === true });
609
+ return;
610
+ }
611
+ if (typeof window !== 'undefined') {
612
+ if (options?.replace === true) {
613
+ window.location.replace(target);
614
+ }
615
+ else {
616
+ window.location.assign(target);
617
+ }
618
+ }
619
+ }, [navigate]);
590
620
  const shouldReturnToCloudWorkspaceSelection = useMemo(() => {
591
621
  try {
592
622
  const parsed = new URL(nextPath, 'https://taskforce.local');
@@ -622,9 +652,9 @@ function TaskforceCoreWithRouter({ config = {}, initialTaskId, onTaskCountChange
622
652
  interval: selectedSignupPlanVersionId ? resolvedSignupInterval : null
623
653
  });
624
654
  }
625
- return nextPath;
655
+ return nextTarget;
626
656
  }, [
627
- nextPath,
657
+ nextTarget,
628
658
  planSelectionPath,
629
659
  resolvedSignupInterval,
630
660
  selectedSignupPlanId,
@@ -688,9 +718,13 @@ function TaskforceCoreWithRouter({ config = {}, initialTaskId, onTaskCountChange
688
718
  ? resolveWorkspaceSetupPath()
689
719
  : pendingPostAuthRedirect.path;
690
720
  const currentRoute = `${location.pathname}${location.search}${location.hash || ''}`;
691
- if (currentRoute !== destination) {
692
- navigate(destination, { replace: true });
721
+ if (destination.startsWith('/')) {
722
+ if (currentRoute !== destination) {
723
+ navigate(destination, { replace: true });
724
+ }
725
+ return;
693
726
  }
727
+ navigateToPostAuthTarget(destination, { replace: true });
694
728
  }, [
695
729
  authSessionResolved,
696
730
  cloudAccountAccessPending,
@@ -699,6 +733,7 @@ function TaskforceCoreWithRouter({ config = {}, initialTaskId, onTaskCountChange
699
733
  location.pathname,
700
734
  location.search,
701
735
  navigate,
736
+ navigateToPostAuthTarget,
702
737
  pendingPostAuthRedirect,
703
738
  resolveWorkspaceSetupPath
704
739
  ]);
@@ -726,9 +761,13 @@ function TaskforceCoreWithRouter({ config = {}, initialTaskId, onTaskCountChange
726
761
  ? pendingPostAuthRedirect.path
727
762
  : planSelectionPath;
728
763
  const currentRoute = `${location.pathname}${location.search}${location.hash || ''}`;
729
- if (currentRoute !== destination) {
730
- navigate(destination, { replace: true });
764
+ if (destination.startsWith('/')) {
765
+ if (currentRoute !== destination) {
766
+ navigate(destination, { replace: true });
767
+ }
768
+ return;
731
769
  }
770
+ navigateToPostAuthTarget(destination, { replace: true });
732
771
  }, [
733
772
  accountAccessGate?.canAccessApp,
734
773
  accountAccessGateResolved,
@@ -739,6 +778,7 @@ function TaskforceCoreWithRouter({ config = {}, initialTaskId, onTaskCountChange
739
778
  location.pathname,
740
779
  location.search,
741
780
  navigate,
781
+ navigateToPostAuthTarget,
742
782
  pendingPostAuthRedirect,
743
783
  planSelectionPath
744
784
  ]);
@@ -901,8 +941,9 @@ function TaskforceCoreWithRouter({ config = {}, initialTaskId, onTaskCountChange
901
941
  if (shouldDelayAuthenticatedLoginRedirect) {
902
942
  return;
903
943
  }
904
- if (isLoginRoute && isAuthenticated && !shouldStayOnInviteRoute)
905
- navigate(nextPath, { replace: true });
944
+ if (isLoginRoute && isAuthenticated && !shouldStayOnInviteRoute) {
945
+ navigateToPostAuthTarget(nextTarget, { replace: true });
946
+ }
906
947
  }
907
948
  else {
908
949
  if (!isLoginRoute) {
@@ -1299,7 +1340,7 @@ function TaskforceCoreWithRouter({ config = {}, initialTaskId, onTaskCountChange
1299
1340
  setLoginBusy(false);
1300
1341
  return;
1301
1342
  }
1302
- navigate(nextPath, { replace: true });
1343
+ navigateToPostAuthTarget(nextTarget, { replace: true });
1303
1344
  }
1304
1345
  else if (authMode === 'login') {
1305
1346
  if (inviteToken.trim()) {
@@ -1319,14 +1360,14 @@ function TaskforceCoreWithRouter({ config = {}, initialTaskId, onTaskCountChange
1319
1360
  setLoginDisplayName('');
1320
1361
  setLoginPassword('');
1321
1362
  setLoginPasswordConfirm('');
1322
- navigate(nextPath, { replace: true });
1363
+ navigateToPostAuthTarget(nextTarget, { replace: true });
1323
1364
  }
1324
1365
  else {
1325
1366
  setLoginEmail('');
1326
1367
  setLoginDisplayName('');
1327
1368
  setLoginPassword('');
1328
1369
  setLoginPasswordConfirm('');
1329
- navigate(nextPath, { replace: true });
1370
+ navigateToPostAuthTarget(nextTarget, { replace: true });
1330
1371
  }
1331
1372
  setLoginBusy(false);
1332
1373
  }, children: loginBusy ? 'Working...' : (authMode === 'login' ? 'Sign In'
@@ -259,11 +259,19 @@ export function registerAdminRoutes(deps) {
259
259
  }
260
260
  };
261
261
  const renderMcpOauthAuthorizePage = (input) => {
262
+ const scopeLabels = {
263
+ 'tasks:read': 'Read tasks',
264
+ 'tasks:write': 'Update tasks',
265
+ 'workspace:read': 'Read workspace'
266
+ };
262
267
  const options = input.workspaces.map((workspace) => {
263
268
  const selected = workspace.id === input.preselectedWorkspaceId ? ' selected' : '';
264
269
  return `<option value="${escapeHtml(workspace.id)}"${selected}>${escapeHtml(workspace.name)} (${escapeHtml(workspace.role)})</option>`;
265
270
  }).join('');
266
- const scopeBadges = input.scopes.map((scope) => `<span style="display:inline-flex;padding:6px 10px;border-radius:999px;background:#272742;border:1px solid rgba(255,255,255,0.09);font-size:12px;">${escapeHtml(scope)}</span>`).join('');
271
+ const scopeBadges = input.scopes.map((scope) => {
272
+ const label = scopeLabels[scope] || scope;
273
+ return `<span class="scope-chip">${escapeHtml(label)}</span>`;
274
+ }).join('');
267
275
  const termsUrl = 'https://taskforcehq.ai/legal/terms/';
268
276
  const privacyUrl = 'https://taskforcehq.ai/legal/privacy/';
269
277
  return `<!doctype html>
@@ -272,47 +280,62 @@ export function registerAdminRoutes(deps) {
272
280
  <meta charset="utf-8" />
273
281
  <meta name="viewport" content="width=device-width, initial-scale=1" />
274
282
  <title>Authorize Taskforce MCP Connector</title>
283
+ <link rel="stylesheet" href="/styles/fonts.css" />
275
284
  <style>
276
285
  :root { color-scheme: dark; }
277
- body { margin: 0; font-family: Inter, system-ui, sans-serif; background: #111126; color: #f8f7ff; }
278
- .wrap { min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 24px; }
279
- .card { width: min(560px, 100%); background: rgba(22,22,44,0.95); border: 1px solid rgba(255,255,255,0.08); border-radius: 20px; box-shadow: 0 20px 80px rgba(0,0,0,0.35); padding: 28px; }
280
- h1 { margin: 0 0 8px; font-size: 24px; }
281
- p { margin: 0 0 16px; color: rgba(255,255,255,0.72); line-height: 1.45; }
282
- label { display: block; font-size: 13px; font-weight: 600; margin: 18px 0 8px; color: rgba(255,255,255,0.9); }
283
- select { width: 100%; border-radius: 14px; border: 1px solid rgba(255,255,255,0.1); background: #1a1a34; color: #fff; padding: 12px 14px; font-size: 14px; }
284
- .scopes { display:flex; flex-wrap:wrap; gap:8px; margin-top:8px; }
285
- .meta { margin-top: 18px; padding: 14px 16px; border-radius: 14px; background: rgba(255,255,255,0.04); border: 1px solid rgba(255,255,255,0.08); }
286
- .meta p { margin: 10px 0 0; font-size: 13px; color: rgba(255,255,255,0.68); }
287
- .error { margin-bottom: 16px; padding: 12px 14px; border-radius: 12px; background: rgba(255,99,132,0.12); color: #ffb6c6; border: 1px solid rgba(255,99,132,0.25); }
288
- .actions { display:flex; justify-content:flex-end; gap:12px; margin-top: 24px; }
289
- button { border-radius: 12px; padding: 11px 16px; font-size: 14px; font-weight: 600; cursor: pointer; border: 1px solid rgba(255,255,255,0.08); }
290
- button[name="decision"][value="deny"] { background: transparent; color: rgba(255,255,255,0.85); }
291
- button[name="decision"][value="approve"] { background: #8d6bff; color: white; border-color: rgba(141,107,255,0.6); }
292
- a { color: #b9d6ff; }
286
+ body { margin: 0; font-family: "Outfit", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: radial-gradient(circle at top, rgba(255,138,0,0.08), transparent 30%), #111126; color: #f8f7ff; }
287
+ .wrap { min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 32px 24px; }
288
+ .card { width: min(720px, 100%); background: rgba(22,22,44,0.95); border: 1px solid rgba(255,255,255,0.08); border-radius: 32px; box-shadow: 0 24px 80px rgba(0,0,0,0.35); padding: 40px 48px; }
289
+ h1 { margin: 0; font-size: clamp(1.95rem, 4.2vw, 2.6rem); line-height: 1.02; letter-spacing: -0.035em; color: #f8f7ff; }
290
+ .lede { margin: 16px 0 8px; font-size: 1.1rem; line-height: 1.45; color: rgba(255,255,255,0.78); max-width: 48ch; text-wrap: pretty; }
291
+ .lede strong { color: #fff; }
292
+ .session-row { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; margin: 0 0 28px; }
293
+ .user-meta { margin: 0; color: rgba(255,255,255,0.5); font-size: 0.9rem; }
294
+ .account-switch { padding: 0; border: 0; min-width: 0; background: transparent; color: #cbd8ff; font-size: 0.9rem; font-weight: 600; box-shadow: none; }
295
+ .account-switch:hover { transform: none; color: #ffffff; text-decoration: underline; }
296
+ label { display: block; font-size: 13px; font-weight: 700; margin: 24px 0 10px; color: rgba(255,255,255,0.92); letter-spacing: 0.01em; }
297
+ select { width: 100%; border-radius: 18px; border: 1px solid rgba(255,255,255,0.1); background: #1f2039; color: #fff; padding: 16px 18px; font-size: 15px; font-weight: 600; box-shadow: inset 0 1px 0 rgba(255,255,255,0.02); }
298
+ .scopes { display:flex; flex-wrap:wrap; gap:10px; margin-top:8px; }
299
+ .scope-chip { display:inline-flex; padding:8px 14px; border-radius:999px; background:#2a2947; border:1px solid rgba(255,255,255,0.09); font-size:13px; font-weight:600; color:rgba(255,255,255,0.92); }
300
+ .legal { margin: 28px 2px 0; color: rgba(255,255,255,0.58); font-size: 0.9rem; line-height: 1.55; max-width: 58ch; }
301
+ .error { margin-bottom: 18px; padding: 12px 14px; border-radius: 14px; background: rgba(255,99,132,0.12); color: #ffb6c6; border: 1px solid rgba(255,99,132,0.25); }
302
+ .actions { display:flex; justify-content:flex-end; gap:14px; margin-top: 36px; }
303
+ button { border-radius: 18px; padding: 15px 22px; font-size: 15px; font-weight: 700; cursor: pointer; border: 1px solid rgba(255,255,255,0.08); min-width: 156px; transition: transform 160ms ease, box-shadow 160ms ease, border-color 160ms ease, background-color 160ms ease; }
304
+ button:hover { transform: translateY(-1px); }
305
+ button[name="decision"][value="deny"] { background: transparent; color: rgba(255,255,255,0.88); border-color: rgba(255,255,255,0.12); }
306
+ button[name="decision"][value="approve"] { background: linear-gradient(135deg, #8d6bff 0%, #7c59f1 100%); color: white; border-color: rgba(141,107,255,0.6); box-shadow: 0 14px 28px rgba(141,107,255,0.26); }
307
+ a { color: #cbd8ff; }
308
+ @media (max-width: 720px) {
309
+ .card { padding: 30px 22px; border-radius: 28px; }
310
+ h1 { font-size: 1.85rem; }
311
+ .lede { max-width: none; }
312
+ .actions { flex-direction: column-reverse; }
313
+ button { width: 100%; }
314
+ }
293
315
  </style>
294
316
  </head>
295
317
  <body>
296
318
  <div class="wrap">
297
319
  <form class="card" method="post" action="/oauth/mcp/authorize">
298
320
  <h1>Authorize Taskforce Connector</h1>
299
- <p>${escapeHtml(input.clientName)} wants access to Taskforce MCP for ${escapeHtml(input.userName)}.</p>
321
+ <p class="lede"><strong>${escapeHtml(input.clientName)}</strong> wants to connect to Taskforce for <strong>${escapeHtml(input.userName)}</strong>.</p>
322
+ <div class="session-row">
323
+ ${input.userMeta ? `<p class="user-meta">Signed in as ${escapeHtml(input.userMeta)}</p>` : ''}
324
+ <button class="account-switch" type="submit" formaction="/api/taskforce/auth/logout" formmethod="post" formnovalidate>Use different account</button>
325
+ </div>
300
326
  ${input.error ? `<div class="error">${escapeHtml(input.error)}</div>` : ''}
301
- <label for="workspaceId">Workspace</label>
327
+ <label for="workspaceId">Select Workspace</label>
302
328
  <select id="workspaceId" name="workspaceId" required>${options}</select>
303
- <label>Requested Scopes</label>
329
+ <label>Requested Permissions</label>
304
330
  <div class="scopes">${scopeBadges}</div>
305
- <div class="meta">
306
- <div><strong>Client:</strong> ${escapeHtml(input.clientName)}</div>
307
- <div><strong>Redirect URI:</strong> ${escapeHtml(input.redirectUri)}</div>
308
- <p>By authorizing this connector, you agree to Taskforce&apos;s <a href="${termsUrl}" target="_blank" rel="noopener noreferrer">Terms of Service</a> and acknowledge the <a href="${privacyUrl}" target="_blank" rel="noopener noreferrer">Privacy Policy</a>.</p>
309
- </div>
331
+ <p class="legal">By authorizing this connector, you agree to Taskforce&apos;s <a href="${termsUrl}" target="_blank" rel="noopener noreferrer">Terms of Service</a> and acknowledge the <a href="${privacyUrl}" target="_blank" rel="noopener noreferrer">Privacy Policy</a>.</p>
310
332
  <input type="hidden" name="client_id" value="${escapeHtml(input.clientId)}" />
311
333
  <input type="hidden" name="redirect_uri" value="${escapeHtml(input.redirectUri)}" />
312
334
  <input type="hidden" name="scope" value="${escapeHtml(input.scopes.join(' '))}" />
313
335
  <input type="hidden" name="state" value="${escapeHtml(input.state)}" />
314
336
  <input type="hidden" name="code_challenge" value="${escapeHtml(input.codeChallenge)}" />
315
337
  <input type="hidden" name="code_challenge_method" value="${escapeHtml(input.codeChallengeMethod)}" />
338
+ <input type="hidden" name="next" value="${escapeHtml(input.authorizeUrl)}" />
316
339
  <div class="actions">
317
340
  <button type="submit" name="decision" value="deny">Cancel</button>
318
341
  <button type="submit" name="decision" value="approve">Authorize</button>
@@ -2481,13 +2504,15 @@ export function registerAdminRoutes(deps) {
2481
2504
  addRoute('POST', '/mcp/', async (req, res) => { await handleCloudMcpRequest(req, res); });
2482
2505
  addRoute('GET', '/mcp', async (req, res) => { await handleCloudMcpRequest(req, res); });
2483
2506
  addRoute('GET', '/mcp/', async (req, res) => { await handleCloudMcpRequest(req, res); });
2507
+ addRoute('HEAD', '/mcp', async (req, res) => { await handleCloudMcpRequest(req, res); });
2508
+ addRoute('HEAD', '/mcp/', async (req, res) => { await handleCloudMcpRequest(req, res); });
2484
2509
  addRoute('DELETE', '/mcp', async (req, res) => { await handleCloudMcpRequest(req, res); });
2485
2510
  addRoute('DELETE', '/mcp/', async (req, res) => { await handleCloudMcpRequest(req, res); });
2486
2511
  addRoute('OPTIONS', '/mcp', async (_req, res) => {
2487
2512
  res.writeHead(204, {
2488
2513
  'Access-Control-Allow-Origin': '*',
2489
2514
  'Access-Control-Allow-Headers': 'authorization, content-type, accept, last-event-id, mcp-protocol-version, mcp-session-id, x-taskforce-workspace-id, x-taskforce-ai-profile-token, x-taskforce-ai-name, x-taskforce-ai-icon, x-taskforce-ai-color, x-taskforce-ai-description, x-taskforce-ai-role, x-taskforce-ai-provider, x-taskforce-ai-model, x-taskforce-ai-surface-type',
2490
- 'Access-Control-Allow-Methods': 'GET, POST, DELETE, OPTIONS'
2515
+ 'Access-Control-Allow-Methods': 'GET, HEAD, POST, DELETE, OPTIONS'
2491
2516
  });
2492
2517
  res.end();
2493
2518
  });
@@ -2495,7 +2520,7 @@ export function registerAdminRoutes(deps) {
2495
2520
  res.writeHead(204, {
2496
2521
  'Access-Control-Allow-Origin': '*',
2497
2522
  'Access-Control-Allow-Headers': 'authorization, content-type, accept, last-event-id, mcp-protocol-version, mcp-session-id, x-taskforce-workspace-id, x-taskforce-ai-profile-token, x-taskforce-ai-name, x-taskforce-ai-icon, x-taskforce-ai-color, x-taskforce-ai-description, x-taskforce-ai-role, x-taskforce-ai-provider, x-taskforce-ai-model, x-taskforce-ai-surface-type',
2498
- 'Access-Control-Allow-Methods': 'GET, POST, DELETE, OPTIONS'
2523
+ 'Access-Control-Allow-Methods': 'GET, HEAD, POST, DELETE, OPTIONS'
2499
2524
  });
2500
2525
  res.end();
2501
2526
  });
@@ -2515,6 +2540,21 @@ export function registerAdminRoutes(deps) {
2515
2540
  scopes_supported: ['tasks:read', 'tasks:write', 'workspace:read'],
2516
2541
  }));
2517
2542
  });
2543
+ addRoute('HEAD', '/.well-known/oauth-authorization-server', async (req, res) => {
2544
+ const issuer = resolveMcpOauthIssuer(req);
2545
+ res.writeHead(200, { 'Content-Type': 'application/json' });
2546
+ res.end(JSON.stringify({
2547
+ issuer,
2548
+ authorization_endpoint: resolveMcpOauthAuthorizeUrl(req),
2549
+ token_endpoint: resolveMcpOauthTokenUrl(req),
2550
+ registration_endpoint: resolveMcpOauthRegisterUrl(req),
2551
+ response_types_supported: ['code'],
2552
+ grant_types_supported: ['authorization_code'],
2553
+ token_endpoint_auth_methods_supported: ['none'],
2554
+ code_challenge_methods_supported: ['S256'],
2555
+ scopes_supported: ['tasks:read', 'tasks:write', 'workspace:read'],
2556
+ }));
2557
+ });
2518
2558
  addRoute('GET', '/.well-known/oauth-protected-resource', async (req, res) => {
2519
2559
  const issuer = resolveMcpOauthIssuer(req);
2520
2560
  res.writeHead(200, { 'Content-Type': 'application/json' });
@@ -2524,6 +2564,15 @@ export function registerAdminRoutes(deps) {
2524
2564
  bearer_methods_supported: ['header'],
2525
2565
  }));
2526
2566
  });
2567
+ addRoute('HEAD', '/.well-known/oauth-protected-resource', async (req, res) => {
2568
+ const issuer = resolveMcpOauthIssuer(req);
2569
+ res.writeHead(200, { 'Content-Type': 'application/json' });
2570
+ res.end(JSON.stringify({
2571
+ resource: resolveMcpOauthResourceUrl(req),
2572
+ authorization_servers: [issuer],
2573
+ bearer_methods_supported: ['header'],
2574
+ }));
2575
+ });
2527
2576
  addRoute('POST', '/oauth/mcp/register', async (req, res) => {
2528
2577
  if (!ensureAppAccess(req, res))
2529
2578
  return;
@@ -2606,8 +2655,77 @@ export function registerAdminRoutes(deps) {
2606
2655
  if (workspaces.length === 0)
2607
2656
  throw new TaskforceRuleError('No accessible workspaces found for this user', 403, 'MCP_OAUTH_WORKSPACE_REQUIRED');
2608
2657
  const preferredWorkspaceId = workspaces.find((workspace) => workspace.id === String(access.workspaceId || '').trim())?.id || workspaces[0].id;
2658
+ const userIdentity = core.resolveUserIdentity(access.userId);
2659
+ const userName = String(userIdentity?.displayName || userIdentity?.email || access.userId).trim() || String(access.userId);
2660
+ const userMeta = userIdentity?.email && userIdentity.email !== userName ? String(userIdentity.email).trim() : null;
2661
+ const authorizeUrl = new URL('/oauth/mcp/authorize', resolveMcpOauthIssuer(req));
2662
+ authorizeUrl.searchParams.set('client_id', clientId);
2663
+ authorizeUrl.searchParams.set('redirect_uri', redirectUri);
2664
+ authorizeUrl.searchParams.set('response_type', responseType);
2665
+ authorizeUrl.searchParams.set('code_challenge', codeChallenge);
2666
+ authorizeUrl.searchParams.set('code_challenge_method', codeChallengeMethod);
2667
+ if (state)
2668
+ authorizeUrl.searchParams.set('state', state);
2669
+ if (scopes.length > 0)
2670
+ authorizeUrl.searchParams.set('scope', scopes.join(' '));
2671
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
2672
+ res.end(renderMcpOauthAuthorizePage({ userName, userMeta, authorizeUrl: authorizeUrl.toString(), workspaces, preselectedWorkspaceId: preferredWorkspaceId, clientName: clientRecord.clientName, clientId: clientRecord.id, redirectUri, scopes, state, codeChallenge, codeChallengeMethod }));
2673
+ }
2674
+ catch (error) {
2675
+ const statusCode = resolveErrorStatusCode(error);
2676
+ res.writeHead(statusCode, { 'Content-Type': 'application/json' });
2677
+ res.end(JSON.stringify({ error: 'invalid_request', error_description: error.message, code: error?.code || 'MCP_OAUTH_AUTHORIZE_FAILED' }));
2678
+ }
2679
+ });
2680
+ addRoute('HEAD', '/oauth/mcp/authorize', async (req, res) => {
2681
+ if (!ensureAppAccess(req, res))
2682
+ return;
2683
+ const reqUrl = new URL(req.url || '/oauth/mcp/authorize', resolveMcpOauthIssuer(req));
2684
+ const clientId = String(reqUrl.searchParams.get('client_id') || '').trim();
2685
+ const redirectUri = String(reqUrl.searchParams.get('redirect_uri') || '').trim();
2686
+ const responseType = String(reqUrl.searchParams.get('response_type') || '').trim();
2687
+ const state = String(reqUrl.searchParams.get('state') || '').trim();
2688
+ const codeChallenge = String(reqUrl.searchParams.get('code_challenge') || '').trim();
2689
+ const codeChallengeMethod = String(reqUrl.searchParams.get('code_challenge_method') || 'S256').trim() || 'S256';
2690
+ const scopes = normalizeMcpOauthScopeString(reqUrl.searchParams.get('scope'));
2691
+ if (responseType !== 'code' || !clientId || !redirectUri || !codeChallenge) {
2692
+ res.writeHead(400, { 'Content-Type': 'application/json' });
2693
+ res.end(JSON.stringify({ error: 'invalid_request', error_description: 'client_id, redirect_uri, response_type=code, and code_challenge are required.' }));
2694
+ return;
2695
+ }
2696
+ const access = context.authConfig ? resolveRequestAccess(req, context.authConfig) : null;
2697
+ if (!access?.authenticated || !access.userId || access.userId === 'anonymous') {
2698
+ res.writeHead(302, { Location: `${resolveCloudLoginBaseUrl(req)}/login?next=${encodeURIComponent(reqUrl.toString())}` });
2699
+ res.end();
2700
+ return;
2701
+ }
2702
+ try {
2703
+ const clientRecord = core.getMcpOAuthClient(clientId);
2704
+ if (!clientRecord)
2705
+ throw new TaskforceRuleError('OAuth client not found', 404, 'MCP_OAUTH_CLIENT_NOT_FOUND');
2706
+ if (!clientRecord.redirectUris.includes(redirectUri))
2707
+ throw new TaskforceRuleError('Redirect URI is not registered for this OAuth client', 400, 'MCP_OAUTH_REDIRECT_URI_INVALID');
2708
+ const workspaces = core.listUserWorkspaces(access.userId)
2709
+ .map((workspace) => ({ id: String(workspace.id || '').trim(), name: String(workspace.name || workspace.slug || workspace.id || 'Workspace'), role: String(workspace.role || 'member') }))
2710
+ .filter((workspace) => Boolean(workspace.id));
2711
+ if (workspaces.length === 0)
2712
+ throw new TaskforceRuleError('No accessible workspaces found for this user', 403, 'MCP_OAUTH_WORKSPACE_REQUIRED');
2713
+ const preferredWorkspaceId = workspaces.find((workspace) => workspace.id === String(access.workspaceId || '').trim())?.id || workspaces[0].id;
2714
+ const userIdentity = core.resolveUserIdentity(access.userId);
2715
+ const userName = String(userIdentity?.displayName || userIdentity?.email || access.userId).trim() || String(access.userId);
2716
+ const userMeta = userIdentity?.email && userIdentity.email !== userName ? String(userIdentity.email).trim() : null;
2717
+ const authorizeUrl = new URL('/oauth/mcp/authorize', resolveMcpOauthIssuer(req));
2718
+ authorizeUrl.searchParams.set('client_id', clientId);
2719
+ authorizeUrl.searchParams.set('redirect_uri', redirectUri);
2720
+ authorizeUrl.searchParams.set('response_type', responseType);
2721
+ authorizeUrl.searchParams.set('code_challenge', codeChallenge);
2722
+ authorizeUrl.searchParams.set('code_challenge_method', codeChallengeMethod);
2723
+ if (state)
2724
+ authorizeUrl.searchParams.set('state', state);
2725
+ if (scopes.length > 0)
2726
+ authorizeUrl.searchParams.set('scope', scopes.join(' '));
2609
2727
  res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
2610
- res.end(renderMcpOauthAuthorizePage({ userName: String(access.userId), workspaces, preselectedWorkspaceId: preferredWorkspaceId, clientName: clientRecord.clientName, clientId: clientRecord.id, redirectUri, scopes, state, codeChallenge, codeChallengeMethod }));
2728
+ res.end(renderMcpOauthAuthorizePage({ userName, userMeta, authorizeUrl: authorizeUrl.toString(), workspaces, preselectedWorkspaceId: preferredWorkspaceId, clientName: clientRecord.clientName, clientId: clientRecord.id, redirectUri, scopes, state, codeChallenge, codeChallengeMethod }));
2611
2729
  }
2612
2730
  catch (error) {
2613
2731
  const statusCode = resolveErrorStatusCode(error);
@@ -2068,7 +2068,31 @@ export function registerAuthRoutes(deps) {
2068
2068
  for (const domain of domains) {
2069
2069
  clearCookies.push(`${clearBase}; Domain=${domain}`);
2070
2070
  }
2071
+ const body = await parseFormBody(req);
2072
+ const requestedNext = String(body.next || '').trim();
2073
+ const next = (() => {
2074
+ if (!requestedNext)
2075
+ return '';
2076
+ try {
2077
+ const parsed = new URL(requestedNext);
2078
+ const host = parsed.hostname.toLowerCase();
2079
+ const isLocal = host === 'localhost' || host === '127.0.0.1' || host === '::1';
2080
+ const isTaskforceHost = host === 'taskforcehq.ai' || host.endsWith('.taskforcehq.ai');
2081
+ if ((parsed.protocol === 'http:' || parsed.protocol === 'https:') && (isLocal || isTaskforceHost)) {
2082
+ return parsed.toString();
2083
+ }
2084
+ }
2085
+ catch {
2086
+ return '';
2087
+ }
2088
+ return '';
2089
+ })();
2071
2090
  res.setHeader('Set-Cookie', [...new Set(clearCookies)]);
2091
+ if (next) {
2092
+ res.writeHead(303, { Location: next });
2093
+ res.end();
2094
+ return;
2095
+ }
2072
2096
  res.writeHead(200, { 'Content-Type': 'application/json' });
2073
2097
  res.end(JSON.stringify({ success: true }));
2074
2098
  });
@@ -1262,11 +1262,19 @@ export function createRoutes(core, context = {}) {
1262
1262
  const resolveMcpOauthRegisterUrl = (req) => `${resolveMcpOauthIssuer(req)}/oauth/mcp/register`;
1263
1263
  const resolveMcpOauthResourceUrl = (req) => `${resolveMcpOauthIssuer(req)}/mcp`;
1264
1264
  const renderMcpOauthAuthorizePage = (input) => {
1265
+ const scopeLabels = {
1266
+ 'tasks:read': 'Read tasks',
1267
+ 'tasks:write': 'Update tasks',
1268
+ 'workspace:read': 'Read workspace'
1269
+ };
1265
1270
  const options = input.workspaces.map((workspace) => {
1266
1271
  const selected = workspace.id === input.preselectedWorkspaceId ? ' selected' : '';
1267
1272
  return `<option value="${escapeHtml(workspace.id)}"${selected}>${escapeHtml(workspace.name)} (${escapeHtml(workspace.role)})</option>`;
1268
1273
  }).join('');
1269
- const scopeBadges = input.scopes.map((scope) => `<span style="display:inline-flex;padding:6px 10px;border-radius:999px;background:#272742;border:1px solid rgba(255,255,255,0.09);font-size:12px;">${escapeHtml(scope)}</span>`).join('');
1274
+ const scopeBadges = input.scopes.map((scope) => {
1275
+ const label = scopeLabels[scope] || scope;
1276
+ return `<span class="scope-chip">${escapeHtml(label)}</span>`;
1277
+ }).join('');
1270
1278
  const termsUrl = 'https://taskforcehq.ai/legal/terms/';
1271
1279
  const privacyUrl = 'https://taskforcehq.ai/legal/privacy/';
1272
1280
  return `<!doctype html>
@@ -1275,47 +1283,62 @@ export function createRoutes(core, context = {}) {
1275
1283
  <meta charset="utf-8" />
1276
1284
  <meta name="viewport" content="width=device-width, initial-scale=1" />
1277
1285
  <title>Authorize Taskforce MCP Connector</title>
1286
+ <link rel="stylesheet" href="/styles/fonts.css" />
1278
1287
  <style>
1279
1288
  :root { color-scheme: dark; }
1280
- body { margin: 0; font-family: Inter, system-ui, sans-serif; background: #111126; color: #f8f7ff; }
1281
- .wrap { min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 24px; }
1282
- .card { width: min(560px, 100%); background: rgba(22,22,44,0.95); border: 1px solid rgba(255,255,255,0.08); border-radius: 20px; box-shadow: 0 20px 80px rgba(0,0,0,0.35); padding: 28px; }
1283
- h1 { margin: 0 0 8px; font-size: 24px; }
1284
- p { margin: 0 0 16px; color: rgba(255,255,255,0.72); line-height: 1.45; }
1285
- label { display: block; font-size: 13px; font-weight: 600; margin: 18px 0 8px; color: rgba(255,255,255,0.9); }
1286
- select { width: 100%; border-radius: 14px; border: 1px solid rgba(255,255,255,0.1); background: #1a1a34; color: #fff; padding: 12px 14px; font-size: 14px; }
1287
- .scopes { display:flex; flex-wrap:wrap; gap:8px; margin-top:8px; }
1288
- .meta { margin-top: 18px; padding: 14px 16px; border-radius: 14px; background: rgba(255,255,255,0.04); border: 1px solid rgba(255,255,255,0.08); }
1289
- .meta p { margin: 10px 0 0; font-size: 13px; color: rgba(255,255,255,0.68); }
1290
- .error { margin-bottom: 16px; padding: 12px 14px; border-radius: 12px; background: rgba(255,99,132,0.12); color: #ffb6c6; border: 1px solid rgba(255,99,132,0.25); }
1291
- .actions { display:flex; justify-content:flex-end; gap:12px; margin-top: 24px; }
1292
- button { border-radius: 12px; padding: 11px 16px; font-size: 14px; font-weight: 600; cursor: pointer; border: 1px solid rgba(255,255,255,0.08); }
1293
- button[name="decision"][value="deny"] { background: transparent; color: rgba(255,255,255,0.85); }
1294
- button[name="decision"][value="approve"] { background: #8d6bff; color: white; border-color: rgba(141,107,255,0.6); }
1295
- a { color: #b9d6ff; }
1289
+ body { margin: 0; font-family: "Outfit", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: radial-gradient(circle at top, rgba(255,138,0,0.08), transparent 30%), #111126; color: #f8f7ff; }
1290
+ .wrap { min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 32px 24px; }
1291
+ .card { width: min(720px, 100%); background: rgba(22,22,44,0.95); border: 1px solid rgba(255,255,255,0.08); border-radius: 32px; box-shadow: 0 24px 80px rgba(0,0,0,0.35); padding: 40px 48px; }
1292
+ h1 { margin: 0; font-size: clamp(1.95rem, 4.2vw, 2.6rem); line-height: 1.02; letter-spacing: -0.035em; color: #f8f7ff; }
1293
+ .lede { margin: 16px 0 8px; font-size: 1.1rem; line-height: 1.45; color: rgba(255,255,255,0.78); max-width: 48ch; text-wrap: pretty; }
1294
+ .lede strong { color: #fff; }
1295
+ .session-row { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; margin: 0 0 28px; }
1296
+ .user-meta { margin: 0; color: rgba(255,255,255,0.5); font-size: 0.9rem; }
1297
+ .account-switch { padding: 0; border: 0; min-width: 0; background: transparent; color: #cbd8ff; font-size: 0.9rem; font-weight: 600; box-shadow: none; }
1298
+ .account-switch:hover { transform: none; color: #ffffff; text-decoration: underline; }
1299
+ label { display: block; font-size: 13px; font-weight: 700; margin: 24px 0 10px; color: rgba(255,255,255,0.92); letter-spacing: 0.01em; }
1300
+ select { width: 100%; border-radius: 18px; border: 1px solid rgba(255,255,255,0.1); background: #1f2039; color: #fff; padding: 16px 18px; font-size: 15px; font-weight: 600; box-shadow: inset 0 1px 0 rgba(255,255,255,0.02); }
1301
+ .scopes { display:flex; flex-wrap:wrap; gap:10px; margin-top:8px; }
1302
+ .scope-chip { display:inline-flex; padding:8px 14px; border-radius:999px; background:#2a2947; border:1px solid rgba(255,255,255,0.09); font-size:13px; font-weight:600; color:rgba(255,255,255,0.92); }
1303
+ .legal { margin: 28px 2px 0; color: rgba(255,255,255,0.58); font-size: 0.9rem; line-height: 1.55; max-width: 58ch; }
1304
+ .error { margin-bottom: 18px; padding: 12px 14px; border-radius: 14px; background: rgba(255,99,132,0.12); color: #ffb6c6; border: 1px solid rgba(255,99,132,0.25); }
1305
+ .actions { display:flex; justify-content:flex-end; gap:14px; margin-top: 36px; }
1306
+ button { border-radius: 18px; padding: 15px 22px; font-size: 15px; font-weight: 700; cursor: pointer; border: 1px solid rgba(255,255,255,0.08); min-width: 156px; transition: transform 160ms ease, box-shadow 160ms ease, border-color 160ms ease, background-color 160ms ease; }
1307
+ button:hover { transform: translateY(-1px); }
1308
+ button[name="decision"][value="deny"] { background: transparent; color: rgba(255,255,255,0.88); border-color: rgba(255,255,255,0.12); }
1309
+ button[name="decision"][value="approve"] { background: linear-gradient(135deg, #8d6bff 0%, #7c59f1 100%); color: white; border-color: rgba(141,107,255,0.6); box-shadow: 0 14px 28px rgba(141,107,255,0.26); }
1310
+ a { color: #cbd8ff; }
1311
+ @media (max-width: 720px) {
1312
+ .card { padding: 30px 22px; border-radius: 28px; }
1313
+ h1 { font-size: 1.85rem; }
1314
+ .lede { max-width: none; }
1315
+ .actions { flex-direction: column-reverse; }
1316
+ button { width: 100%; }
1317
+ }
1296
1318
  </style>
1297
1319
  </head>
1298
1320
  <body>
1299
1321
  <div class="wrap">
1300
1322
  <form class="card" method="post" action="/oauth/mcp/authorize">
1301
1323
  <h1>Authorize Taskforce Connector</h1>
1302
- <p>${escapeHtml(input.clientName)} wants access to Taskforce MCP for ${escapeHtml(input.userName)}.</p>
1324
+ <p class="lede"><strong>${escapeHtml(input.clientName)}</strong> wants to connect to Taskforce for <strong>${escapeHtml(input.userName)}</strong>.</p>
1325
+ <div class="session-row">
1326
+ ${input.userMeta ? `<p class="user-meta">Signed in as ${escapeHtml(input.userMeta)}</p>` : ''}
1327
+ <button class="account-switch" type="submit" formaction="/api/taskforce/auth/logout" formmethod="post" formnovalidate>Use different account</button>
1328
+ </div>
1303
1329
  ${input.error ? `<div class="error">${escapeHtml(input.error)}</div>` : ''}
1304
- <label for="workspaceId">Workspace</label>
1330
+ <label for="workspaceId">Select Workspace</label>
1305
1331
  <select id="workspaceId" name="workspaceId" required>${options}</select>
1306
- <label>Requested Scopes</label>
1332
+ <label>Requested Permissions</label>
1307
1333
  <div class="scopes">${scopeBadges}</div>
1308
- <div class="meta">
1309
- <div><strong>Client:</strong> ${escapeHtml(input.clientName)}</div>
1310
- <div><strong>Redirect URI:</strong> ${escapeHtml(input.redirectUri)}</div>
1311
- <p>By authorizing this connector, you agree to Taskforce&apos;s <a href="${termsUrl}" target="_blank" rel="noopener noreferrer">Terms of Service</a> and acknowledge the <a href="${privacyUrl}" target="_blank" rel="noopener noreferrer">Privacy Policy</a>.</p>
1312
- </div>
1334
+ <p class="legal">By authorizing this connector, you agree to Taskforce&apos;s <a href="${termsUrl}" target="_blank" rel="noopener noreferrer">Terms of Service</a> and acknowledge the <a href="${privacyUrl}" target="_blank" rel="noopener noreferrer">Privacy Policy</a>.</p>
1313
1335
  <input type="hidden" name="client_id" value="${escapeHtml(input.clientId)}" />
1314
1336
  <input type="hidden" name="redirect_uri" value="${escapeHtml(input.redirectUri)}" />
1315
1337
  <input type="hidden" name="scope" value="${escapeHtml(input.scopes.join(' '))}" />
1316
1338
  <input type="hidden" name="state" value="${escapeHtml(input.state)}" />
1317
1339
  <input type="hidden" name="code_challenge" value="${escapeHtml(input.codeChallenge)}" />
1318
1340
  <input type="hidden" name="code_challenge_method" value="${escapeHtml(input.codeChallengeMethod)}" />
1341
+ <input type="hidden" name="next" value="${escapeHtml(input.authorizeUrl)}" />
1319
1342
  <div class="actions">
1320
1343
  <button type="submit" name="decision" value="deny">Cancel</button>
1321
1344
  <button type="submit" name="decision" value="approve">Authorize</button>