diff --git a/client/src/App.tsx b/client/src/App.tsx index 06640508..a97034f2 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -13,6 +13,7 @@ import AtlasPage from './pages/AtlasPage' import SharedTripPage from './pages/SharedTripPage' import InAppNotificationsPage from './pages/InAppNotificationsPage.tsx' import OAuthAuthorizePage from './pages/OAuthAuthorizePage' +import OAuthRegisterPage from './pages/OAuthRegisterPage' import { ToastContainer } from './components/shared/Toast' import { TranslationProvider, useTranslation } from './i18n' import { authApi } from './api/client' @@ -166,6 +167,7 @@ export default function App() { } /> {/* OAuth 2.1 consent page — intentionally outside ProtectedRoute */} } /> + } /> apiClient.post('/oauth/authorize', body).then(r => r.data), + register: { + /** Validate DCR params — called by registration page on load */ + validate: (params: { redirect_uri: string; client_name?: string; scope?: string; state?: string }) => + apiClient.get('/oauth/register/validate', { params }).then(r => r.data), + /** Submit registration approval or cancellation */ + submit: (body: { client_name: string; redirect_uri: string; scopes: string[]; state?: string; approved: boolean }) => + apiClient.post('/oauth/register', body).then(r => r.data), + }, + clients: { list: () => apiClient.get('/oauth/clients').then(r => r.data), create: (data: { name: string; redirect_uris: string[]; allowed_scopes: string[] }) => diff --git a/client/src/components/OAuth/ScopeGroupPicker.tsx b/client/src/components/OAuth/ScopeGroupPicker.tsx new file mode 100644 index 00000000..8d7e8c18 --- /dev/null +++ b/client/src/components/OAuth/ScopeGroupPicker.tsx @@ -0,0 +1,95 @@ +import React, { useState } from 'react' +import { ChevronDown, ChevronRight } from 'lucide-react' +import { getScopesByGroup } from '../../api/oauthScopes' + +interface Props { + selected: string[] + onChange: (scopes: string[]) => void +} + +const scopesByGroup = getScopesByGroup() + +export default function ScopeGroupPicker({ selected, onChange }: Props): React.ReactElement { + const [open, setOpen] = useState>({}) + + const allScopeKeys = Object.values(scopesByGroup).flat().map(s => s.scope) + const allSelected = allScopeKeys.every(s => selected.includes(s)) + + return ( +
+
+ +
+
+ {Object.entries(scopesByGroup).map(([group, groupScopes]) => { + const groupScopeKeys = groupScopes.map(s => s.scope) + const allGroupSelected = groupScopeKeys.every(s => selected.includes(s)) + const someGroupSelected = groupScopeKeys.some(s => selected.includes(s)) + return ( +
+
+ + { if (el) el.indeterminate = someGroupSelected && !allGroupSelected }} + onChange={e => onChange( + e.target.checked + ? [...new Set([...selected, ...groupScopeKeys])] + : selected.filter(s => !groupScopeKeys.includes(s)) + )} + className="rounded" + title={allGroupSelected ? `Deselect all ${group}` : `Select all ${group}`} + /> +
+ {open[group] && ( +
+ {groupScopes.map(({ scope, label, description }) => ( + + ))} +
+ )} +
+ ) + })} +
+
+ ) +} diff --git a/client/src/components/Settings/IntegrationsTab.test.tsx b/client/src/components/Settings/IntegrationsTab.test.tsx index 84eeb161..c7b7f1ae 100644 --- a/client/src/components/Settings/IntegrationsTab.test.tsx +++ b/client/src/components/Settings/IntegrationsTab.test.tsx @@ -69,18 +69,26 @@ describe('IntegrationsTab', () => { expect(codeEl!.textContent).toContain('/mcp'); }); - it('FE-COMP-INTEGRATIONS-005: JSON config block is rendered', async () => { + it('FE-COMP-INTEGRATIONS-005: JSON config block is rendered when expanded', async () => { + const user = userEvent.setup(); enableMcp(); render(); await screen.findByText('MCP Configuration'); + // Config is collapsed by default — no
 yet
+    expect(document.querySelector('pre')).toBeNull();
+    // Expand by clicking the "Client Configuration" toggle
+    await user.click(screen.getByRole('button', { name: /Client Configuration/i }));
     const preEl = document.querySelector('pre');
     expect(preEl).not.toBeNull();
     expect(preEl!.textContent).toContain('mcpServers');
   });
 
   it('FE-COMP-INTEGRATIONS-006: "no tokens" message shown when token list is empty', async () => {
+    const user = userEvent.setup();
     enableMcp();
     render();
+    await screen.findByText('MCP Configuration');
+    await user.click(screen.getByRole('button', { name: /API Tokens/i }));
     await screen.findByText('No tokens yet. Create one to connect MCP clients.');
   });
 
@@ -95,8 +103,11 @@ describe('IntegrationsTab', () => {
         }),
       ),
     );
+    const user = userEvent.setup();
     enableMcp();
     render();
+    await screen.findByText('MCP Configuration');
+    await user.click(screen.getByRole('button', { name: /API Tokens/i }));
     await screen.findByText('My Token');
     await screen.findByText('Other Token');
   });
@@ -106,6 +117,7 @@ describe('IntegrationsTab', () => {
     enableMcp();
     render();
     await screen.findByText('MCP Configuration');
+    await user.click(screen.getByRole('button', { name: /API Tokens/i }));
     const createBtn = screen.getByRole('button', { name: /Create New Token/i });
     await user.click(createBtn);
     await screen.findByText('Create API Token');
@@ -116,6 +128,7 @@ describe('IntegrationsTab', () => {
     enableMcp();
     render();
     await screen.findByText('MCP Configuration');
+    await user.click(screen.getByRole('button', { name: /API Tokens/i }));
     await user.click(screen.getByRole('button', { name: /Create New Token/i }));
     await screen.findByText('Create API Token');
     const modalCreateBtn = screen.getByRole('button', { name: /^Create Token$/i });
@@ -127,6 +140,7 @@ describe('IntegrationsTab', () => {
     enableMcp();
     render();
     await screen.findByText('MCP Configuration');
+    await user.click(screen.getByRole('button', { name: /API Tokens/i }));
     await user.click(screen.getByRole('button', { name: /Create New Token/i }));
     await screen.findByText('Create API Token');
     const input = screen.getByPlaceholderText(/Claude Desktop/i);
@@ -153,6 +167,7 @@ describe('IntegrationsTab', () => {
     enableMcp();
     render();
     await screen.findByText('MCP Configuration');
+    await user.click(screen.getByRole('button', { name: /API Tokens/i }));
     await user.click(screen.getByRole('button', { name: /Create New Token/i }));
     await screen.findByText('Create API Token');
     const input = screen.getByPlaceholderText(/Claude Desktop/i);
@@ -182,6 +197,7 @@ describe('IntegrationsTab', () => {
     enableMcp();
     render();
     await screen.findByText('MCP Configuration');
+    await user.click(screen.getByRole('button', { name: /API Tokens/i }));
     await user.click(screen.getByRole('button', { name: /Create New Token/i }));
     await screen.findByText('Create API Token');
     await user.type(screen.getByPlaceholderText(/Claude Desktop/i), 'test');
@@ -206,6 +222,8 @@ describe('IntegrationsTab', () => {
     const user = userEvent.setup();
     enableMcp();
     render();
+    await screen.findByText('MCP Configuration');
+    await user.click(screen.getByRole('button', { name: /API Tokens/i }));
     await screen.findByText('Delete Me');
     await user.click(screen.getByTitle('Delete Token'));
     await screen.findByText('This token will stop working immediately. Any MCP client using it will lose access.');
@@ -230,6 +248,8 @@ describe('IntegrationsTab', () => {
     const user = userEvent.setup();
     enableMcp();
     render();
+    await screen.findByText('MCP Configuration');
+    await user.click(screen.getByRole('button', { name: /API Tokens/i }));
     await screen.findByText('Delete Me');
     await user.click(screen.getByTitle('Delete Token'));
     // There are two "Delete Token" buttons: the trash icon (title) and the confirm button in modal
@@ -289,6 +309,8 @@ describe('IntegrationsTab', () => {
     const user = userEvent.setup();
     enableMcp();
     render();
+    await screen.findByText('MCP Configuration');
+    await user.click(screen.getByRole('button', { name: /API Tokens/i }));
     await screen.findByText('Cancel Token');
     await user.click(screen.getByTitle('Delete Token'));
     await screen.findByRole('button', { name: /^Cancel$/i });
@@ -319,6 +341,7 @@ describe('IntegrationsTab', () => {
     enableMcp();
     render();
     await screen.findByText('MCP Configuration');
+    await user.click(screen.getByRole('button', { name: /API Tokens/i }));
     await user.click(screen.getByRole('button', { name: /Create New Token/i }));
     await screen.findByText('Create API Token');
     const input = screen.getByPlaceholderText(/Claude Desktop/i);
@@ -328,4 +351,32 @@ describe('IntegrationsTab', () => {
       expect(postCalled).toBe(true);
     });
   });
+
+  it('FE-COMP-INTEGRATIONS-019: default tab is OAuth 2.1 Clients — OAuth hint visible, token list hidden', async () => {
+    enableMcp();
+    render();
+    await screen.findByText('MCP Configuration');
+    // OAuth hint is visible on the default tab
+    expect(screen.getByText(/Register OAuth 2\.1 clients/i)).toBeInTheDocument();
+    // API Tokens "no tokens" message is not rendered
+    expect(screen.queryByText('No tokens yet. Create one to connect MCP clients.')).toBeNull();
+  });
+
+  it('FE-COMP-INTEGRATIONS-020: switching tabs toggles content visibility', async () => {
+    const user = userEvent.setup();
+    enableMcp();
+    render();
+    await screen.findByText('MCP Configuration');
+    // Default: OAuth hint visible, token list absent
+    expect(screen.getByText(/Register OAuth 2\.1 clients/i)).toBeInTheDocument();
+    expect(screen.queryByText('No tokens yet. Create one to connect MCP clients.')).toBeNull();
+    // Switch to API Tokens tab
+    await user.click(screen.getByRole('button', { name: /API Tokens/i }));
+    await screen.findByText('No tokens yet. Create one to connect MCP clients.');
+    expect(screen.queryByText(/Register OAuth 2\.1 clients/i)).toBeNull();
+    // Switch back to OAuth tab
+    await user.click(screen.getByRole('button', { name: /OAuth 2\.1 Clients/i }));
+    await screen.findByText(/Register OAuth 2\.1 clients/i);
+    expect(screen.queryByText('No tokens yet. Create one to connect MCP clients.')).toBeNull();
+  });
 });
diff --git a/client/src/components/Settings/IntegrationsTab.tsx b/client/src/components/Settings/IntegrationsTab.tsx
index 4bbc3413..9516dc2c 100644
--- a/client/src/components/Settings/IntegrationsTab.tsx
+++ b/client/src/components/Settings/IntegrationsTab.tsx
@@ -2,11 +2,12 @@ import Section from './Section'
 import React, { useEffect, useState } from 'react'
 import { useTranslation } from '../../i18n'
 import { useToast } from '../shared/Toast'
-import { Trash2, Copy, Terminal, Plus, Check, KeyRound, ShieldCheck, ChevronDown, ChevronRight, RefreshCw } from 'lucide-react'
+import { Trash2, Copy, Terminal, Plus, Check, KeyRound, ChevronDown, ChevronRight, RefreshCw } from 'lucide-react'
 import { authApi, oauthApi } from '../../api/client'
 import { useAddonStore } from '../../store/addonStore'
 import PhotoProvidersSection from './PhotoProvidersSection'
-import { getScopesByGroup, ALL_SCOPES } from '../../api/oauthScopes'
+import { ALL_SCOPES } from '../../api/oauthScopes'
+import ScopeGroupPicker from '../OAuth/ScopeGroupPicker'
 
 interface OAuthPreset {
   id: string
@@ -114,9 +115,14 @@ export default function IntegrationsTab(): React.ReactElement {
   const [oauthRotateId, setOauthRotateId] = useState(null)
   const [oauthRotatedSecret, setOauthRotatedSecret] = useState(null)
   const [oauthRotating, setOauthRotating] = useState(false)
-  const [oauthScopesOpen, setOauthScopesOpen] = useState>({})
+  // oauthScopesOpen is managed internally by ScopeGroupPicker
   const [oauthScopesExpanded, setOauthScopesExpanded] = useState>({})
 
+  // MCP sub-tab state
+  const [activeMcpTab, setActiveMcpTab] = useState<'oauth' | 'apitokens'>('oauth')
+  const [configOpenOAuth, setConfigOpenOAuth] = useState(false)
+  const [configOpenToken, setConfigOpenToken] = useState(false)
+
   // MCP state
   const [mcpTokens, setMcpTokens] = useState([])
   const [mcpModalOpen, setMcpModalOpen] = useState(false)
@@ -127,6 +133,19 @@ export default function IntegrationsTab(): React.ReactElement {
   const [copiedKey, setCopiedKey] = useState(null)
 
   const mcpEndpoint = `${window.location.origin}/mcp`
+  const mcpJsonConfigOAuth = `{
+  "mcpServers": {
+    "trek": {
+      "command": "npx",
+      "args": [
+        "mcp-remote",
+        "${mcpEndpoint}",
+        "--static-oauth-client-info",
+        "{\\"client_id\\": \\"\\", \\"client_secret\\": \\"\\"}"
+      ]
+    }
+  }
+}`
   const mcpJsonConfig = `{
   "mcpServers": {
     "trek": {
@@ -241,8 +260,6 @@ export default function IntegrationsTab(): React.ReactElement {
     }
   }
 
-  const scopesByGroup = getScopesByGroup()
-
   return (
     <>
       
@@ -263,113 +280,193 @@ export default function IntegrationsTab(): React.ReactElement {
             
           
 
-          {/* JSON config box */}
-          
-
- - -
-
-              {mcpJsonConfig}
-            
-

{t('settings.mcp.clientConfigHint')}

+ {/* Sub-tab bar */} +
+ +
- {/* OAuth Clients */} -
-
- - -
-

{t('settings.oauth.clientsHint')}

- -
- -
- - {oauthClients.length === 0 ? ( -

- {t('settings.oauth.noClients')} -

- ) : ( + {/* OAuth 2.1 Clients tab */} + {activeMcpTab === 'oauth' && ( + <> + {/* JSON config — OAuth (collapsible) */}
- {oauthClients.map((client, i) => ( -
-
- -
-

{client.name}

-

- {t('settings.oauth.clientId')}: {client.client_id} - {t('settings.mcp.tokenCreatedAt')} {new Date(client.created_at).toLocaleDateString(locale)} -

-
- {(oauthScopesExpanded[client.id] ? client.allowed_scopes : client.allowed_scopes.slice(0, 5)).map(s => ( - {s} - ))} - {client.allowed_scopes.length > 5 && ( - - )} -
-
- - + {configOpenOAuth && ( +
+
+
+
+                      {mcpJsonConfigOAuth}
+                    
+

{t('settings.mcp.clientConfigHintOAuth')}

- ))} + )}
- )} -
- {/* Token list — deprecated */} -
-
-
- - - Deprecated - +
+

{t('settings.oauth.clientsHint')}

+ +
+ +
+ + {oauthClients.length === 0 ? ( +

+ {t('settings.oauth.noClients')} +

+ ) : ( +
+ {oauthClients.map((client, i) => ( +
+
+ +
+

{client.name}

+

+ {t('settings.oauth.clientId')}: {client.client_id} + {t('settings.mcp.tokenCreatedAt')} {new Date(client.created_at).toLocaleDateString(locale)} +

+
+ {(oauthScopesExpanded[client.id] ? client.allowed_scopes : client.allowed_scopes.slice(0, 5)).map(s => ( + {s} + ))} + {client.allowed_scopes.length > 5 && ( + + )} +
+
+ + +
+
+ ))} +
+ )}
- -
-
- -

{t('settings.mcp.apiTokensDeprecated')}

-
-
+ + {/* Active OAuth Sessions */} + {oauthSessions.length > 0 && ( +
+ +
+ {oauthSessions.map((session, i) => ( +
+
+

{session.client_name}

+

+ {t('settings.oauth.sessionScopes')}: {session.scopes.join(', ')} + {t('settings.oauth.sessionExpires')} {new Date(session.access_token_expires_at).toLocaleDateString(locale)} +

+
+ +
+ ))} +
+
+ )} + + )} + + {/* API Tokens tab (deprecated) */} + {activeMcpTab === 'apitokens' && ( + <> +
+ +

{t('settings.mcp.apiTokensDeprecated')}

+
+ + {/* JSON config — API Token (collapsible) */} +
+ + {configOpenToken && ( +
+
+ +
+
+                      {mcpJsonConfig}
+                    
+

{t('settings.mcp.clientConfigHint')}

+
+ )} +
+ +
+ +
+ {mcpTokens.length === 0 ? (

{t('settings.mcp.noTokens')}

) : ( -
+
{mcpTokens.map((token, i) => (
@@ -392,33 +489,7 @@ export default function IntegrationsTab(): React.ReactElement { ))}
)} -
-
- - {/* Active OAuth Sessions */} - {oauthSessions.length > 0 && ( -
- -
- {oauthSessions.map((session, i) => ( -
-
-

{session.client_name}

-

- {t('settings.oauth.sessionScopes')}: {session.scopes.join(', ')} - {t('settings.oauth.sessionExpires')} {new Date(session.access_token_expires_at).toLocaleDateString(locale)} -

-
- -
- ))} -
-
+ )} )} @@ -436,7 +507,7 @@ export default function IntegrationsTab(): React.ReactElement { setMcpNewName(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleCreateMcpToken()} placeholder={t('settings.mcp.modal.tokenNamePlaceholder')} - className="w-full px-3 py-2.5 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-indigo-300" + className="w-full px-3 py-2.5 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-slate-400" style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-secondary)', color: 'var(--text-primary)' }} autoFocus />
@@ -446,8 +517,7 @@ export default function IntegrationsTab(): React.ReactElement { {t('common.cancel')}
@@ -471,8 +541,7 @@ export default function IntegrationsTab(): React.ReactElement {
@@ -536,7 +605,7 @@ export default function IntegrationsTab(): React.ReactElement { setOauthNewName(e.target.value)} placeholder={t('settings.oauth.modal.clientNamePlaceholder')} - className="w-full px-3 py-2.5 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-indigo-300" + className="w-full px-3 py-2.5 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-slate-400" style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-secondary)', color: 'var(--text-primary)' }} autoFocus />
@@ -546,78 +615,15 @@ export default function IntegrationsTab(): React.ReactElement {