feat(oauth): browser-initiated dynamic client registration (DCR)

Adds an OAuth 2.1 public client registration flow so MCP clients can
self-register via a user-facing consent page instead of requiring manual
setup in Settings.

Server:
- DB migration adds `is_public` and `created_via` columns to oauth_clients
- New GET /api/oauth/register/validate — validates DCR params, returns
  requested scopes; unauthenticated callers get loginRequired flag
- New POST /api/oauth/register — creates a public client, saves consent,
  and redirects with client_id (cookie auth required)
- `authenticateClient` / `refreshTokens` skip secret check for public
  clients (PKCE provides the security guarantee)
- `createOAuthClient` accepts options for isPublic/createdVia; public
  clients store an opaque secret hash instead of a usable secret
- `rotateOAuthClientSecret` blocked on public clients
- `isValidRedirectUri` extracted as a shared helper
- Discovery metadata now advertises registration_endpoint and auth method
  `none`; token/revoke endpoints no longer require client_secret for
  public clients

Client:
- New OAuthRegisterPage (/oauth/register) — loading → optional
  login-required gate → scope selection → done states
- New ScopeGroupPicker component — collapsible groups, indeterminate
  checkboxes, select-all per group or globally
- oauthApi.register.{validate,submit} added to api/client.ts
- apiClient exported so it can be reused outside api/client.ts
- IntegrationsTab tests fixed for new collapsible section structure
- collab_notes fallback changed from undefined to [] in MCP trip tools
This commit is contained in:
jubnl
2026-04-10 05:20:38 +02:00
parent 81a360f9a7
commit 9b1baaf7b8
25 changed files with 739 additions and 235 deletions
+2
View File
@@ -13,6 +13,7 @@ import AtlasPage from './pages/AtlasPage'
import SharedTripPage from './pages/SharedTripPage' import SharedTripPage from './pages/SharedTripPage'
import InAppNotificationsPage from './pages/InAppNotificationsPage.tsx' import InAppNotificationsPage from './pages/InAppNotificationsPage.tsx'
import OAuthAuthorizePage from './pages/OAuthAuthorizePage' import OAuthAuthorizePage from './pages/OAuthAuthorizePage'
import OAuthRegisterPage from './pages/OAuthRegisterPage'
import { ToastContainer } from './components/shared/Toast' import { ToastContainer } from './components/shared/Toast'
import { TranslationProvider, useTranslation } from './i18n' import { TranslationProvider, useTranslation } from './i18n'
import { authApi } from './api/client' import { authApi } from './api/client'
@@ -166,6 +167,7 @@ export default function App() {
<Route path="/register" element={<LoginPage />} /> <Route path="/register" element={<LoginPage />} />
{/* OAuth 2.1 consent page — intentionally outside ProtectedRoute */} {/* OAuth 2.1 consent page — intentionally outside ProtectedRoute */}
<Route path="/oauth/authorize" element={<OAuthAuthorizePage />} /> <Route path="/oauth/authorize" element={<OAuthAuthorizePage />} />
<Route path="/oauth/register" element={<OAuthRegisterPage />} />
<Route <Route
path="/dashboard" path="/dashboard"
element={ element={
+10 -1
View File
@@ -1,7 +1,7 @@
import axios, { AxiosInstance } from 'axios' import axios, { AxiosInstance } from 'axios'
import { getSocketId } from './websocket' import { getSocketId } from './websocket'
const apiClient: AxiosInstance = axios.create({ export const apiClient: AxiosInstance = axios.create({
baseURL: '/api', baseURL: '/api',
withCredentials: true, withCredentials: true,
headers: { headers: {
@@ -95,6 +95,15 @@ export const oauthApi = {
approved: boolean approved: boolean
}) => apiClient.post('/oauth/authorize', body).then(r => r.data), }) => 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: { clients: {
list: () => apiClient.get('/oauth/clients').then(r => r.data), list: () => apiClient.get('/oauth/clients').then(r => r.data),
create: (data: { name: string; redirect_uris: string[]; allowed_scopes: string[] }) => create: (data: { name: string; redirect_uris: string[]; allowed_scopes: string[] }) =>
@@ -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<Record<string, boolean>>({})
const allScopeKeys = Object.values(scopesByGroup).flat().map(s => s.scope)
const allSelected = allScopeKeys.every(s => selected.includes(s))
return (
<div className="space-y-1">
<div className="flex justify-end mb-2">
<button
type="button"
onClick={() => onChange(allSelected ? [] : allScopeKeys)}
className="text-xs px-2 py-0.5 rounded border transition-colors hover:bg-slate-100 dark:hover:bg-slate-700"
style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}>
{allSelected ? 'Deselect all' : 'Select all'}
</button>
</div>
<div className="space-y-1 max-h-96 overflow-y-auto pr-1">
{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 (
<div key={group} className="rounded-lg border overflow-hidden" style={{ borderColor: 'var(--border-primary)' }}>
<div className="flex items-center gap-1 px-3 py-2" style={{ background: 'var(--bg-secondary)' }}>
<button
type="button"
onClick={() => setOpen(prev => ({ ...prev, [group]: !prev[group] }))}
className="flex items-center gap-1 flex-1 text-xs font-semibold hover:opacity-70 transition-opacity text-left"
style={{ color: 'var(--text-secondary)' }}>
{open[group]
? <ChevronDown className="w-3 h-3 flex-shrink-0" />
: <ChevronRight className="w-3 h-3 flex-shrink-0" />}
{group}
{someGroupSelected && (
<span className="ml-1.5 text-xs font-normal" style={{ color: 'var(--text-tertiary)' }}>
({groupScopeKeys.filter(s => selected.includes(s)).length}/{groupScopeKeys.length})
</span>
)}
</button>
<input
type="checkbox"
checked={allGroupSelected}
ref={el => { 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}`}
/>
</div>
{open[group] && (
<div className="divide-y" style={{ borderColor: 'var(--border-primary)' }}>
{groupScopes.map(({ scope, label, description }) => (
<label
key={scope}
className="flex items-start gap-2.5 px-3 py-2 cursor-pointer hover:bg-slate-50 dark:hover:bg-slate-800/50 transition-colors">
<input
type="checkbox"
checked={selected.includes(scope)}
onChange={e => onChange(
e.target.checked
? [...selected, scope]
: selected.filter(s => s !== scope)
)}
className="mt-0.5 rounded flex-shrink-0"
/>
<div>
<p className="text-xs font-medium" style={{ color: 'var(--text-primary)' }}>{label}</p>
<p className="text-xs" style={{ color: 'var(--text-tertiary)' }}>{description}</p>
</div>
</label>
))}
</div>
)}
</div>
)
})}
</div>
</div>
)
}
@@ -69,18 +69,26 @@ describe('IntegrationsTab', () => {
expect(codeEl!.textContent).toContain('/mcp'); 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(); enableMcp();
render(<IntegrationsTab />); render(<IntegrationsTab />);
await screen.findByText('MCP Configuration'); await screen.findByText('MCP Configuration');
// Config is collapsed by default — no <pre> 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'); const preEl = document.querySelector('pre');
expect(preEl).not.toBeNull(); expect(preEl).not.toBeNull();
expect(preEl!.textContent).toContain('mcpServers'); expect(preEl!.textContent).toContain('mcpServers');
}); });
it('FE-COMP-INTEGRATIONS-006: "no tokens" message shown when token list is empty', async () => { it('FE-COMP-INTEGRATIONS-006: "no tokens" message shown when token list is empty', async () => {
const user = userEvent.setup();
enableMcp(); enableMcp();
render(<IntegrationsTab />); render(<IntegrationsTab />);
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.'); await screen.findByText('No tokens yet. Create one to connect MCP clients.');
}); });
@@ -95,8 +103,11 @@ describe('IntegrationsTab', () => {
}), }),
), ),
); );
const user = userEvent.setup();
enableMcp(); enableMcp();
render(<IntegrationsTab />); render(<IntegrationsTab />);
await screen.findByText('MCP Configuration');
await user.click(screen.getByRole('button', { name: /API Tokens/i }));
await screen.findByText('My Token'); await screen.findByText('My Token');
await screen.findByText('Other Token'); await screen.findByText('Other Token');
}); });
@@ -106,6 +117,7 @@ describe('IntegrationsTab', () => {
enableMcp(); enableMcp();
render(<IntegrationsTab />); render(<IntegrationsTab />);
await screen.findByText('MCP Configuration'); 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 }); const createBtn = screen.getByRole('button', { name: /Create New Token/i });
await user.click(createBtn); await user.click(createBtn);
await screen.findByText('Create API Token'); await screen.findByText('Create API Token');
@@ -116,6 +128,7 @@ describe('IntegrationsTab', () => {
enableMcp(); enableMcp();
render(<IntegrationsTab />); render(<IntegrationsTab />);
await screen.findByText('MCP Configuration'); 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 user.click(screen.getByRole('button', { name: /Create New Token/i }));
await screen.findByText('Create API Token'); await screen.findByText('Create API Token');
const modalCreateBtn = screen.getByRole('button', { name: /^Create Token$/i }); const modalCreateBtn = screen.getByRole('button', { name: /^Create Token$/i });
@@ -127,6 +140,7 @@ describe('IntegrationsTab', () => {
enableMcp(); enableMcp();
render(<IntegrationsTab />); render(<IntegrationsTab />);
await screen.findByText('MCP Configuration'); 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 user.click(screen.getByRole('button', { name: /Create New Token/i }));
await screen.findByText('Create API Token'); await screen.findByText('Create API Token');
const input = screen.getByPlaceholderText(/Claude Desktop/i); const input = screen.getByPlaceholderText(/Claude Desktop/i);
@@ -153,6 +167,7 @@ describe('IntegrationsTab', () => {
enableMcp(); enableMcp();
render(<IntegrationsTab />); render(<IntegrationsTab />);
await screen.findByText('MCP Configuration'); 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 user.click(screen.getByRole('button', { name: /Create New Token/i }));
await screen.findByText('Create API Token'); await screen.findByText('Create API Token');
const input = screen.getByPlaceholderText(/Claude Desktop/i); const input = screen.getByPlaceholderText(/Claude Desktop/i);
@@ -182,6 +197,7 @@ describe('IntegrationsTab', () => {
enableMcp(); enableMcp();
render(<IntegrationsTab />); render(<IntegrationsTab />);
await screen.findByText('MCP Configuration'); 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 user.click(screen.getByRole('button', { name: /Create New Token/i }));
await screen.findByText('Create API Token'); await screen.findByText('Create API Token');
await user.type(screen.getByPlaceholderText(/Claude Desktop/i), 'test'); await user.type(screen.getByPlaceholderText(/Claude Desktop/i), 'test');
@@ -206,6 +222,8 @@ describe('IntegrationsTab', () => {
const user = userEvent.setup(); const user = userEvent.setup();
enableMcp(); enableMcp();
render(<IntegrationsTab />); render(<IntegrationsTab />);
await screen.findByText('MCP Configuration');
await user.click(screen.getByRole('button', { name: /API Tokens/i }));
await screen.findByText('Delete Me'); await screen.findByText('Delete Me');
await user.click(screen.getByTitle('Delete Token')); await user.click(screen.getByTitle('Delete Token'));
await screen.findByText('This token will stop working immediately. Any MCP client using it will lose access.'); 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(); const user = userEvent.setup();
enableMcp(); enableMcp();
render(<IntegrationsTab />); render(<IntegrationsTab />);
await screen.findByText('MCP Configuration');
await user.click(screen.getByRole('button', { name: /API Tokens/i }));
await screen.findByText('Delete Me'); await screen.findByText('Delete Me');
await user.click(screen.getByTitle('Delete Token')); await user.click(screen.getByTitle('Delete Token'));
// There are two "Delete Token" buttons: the trash icon (title) and the confirm button in modal // 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(); const user = userEvent.setup();
enableMcp(); enableMcp();
render(<IntegrationsTab />); render(<IntegrationsTab />);
await screen.findByText('MCP Configuration');
await user.click(screen.getByRole('button', { name: /API Tokens/i }));
await screen.findByText('Cancel Token'); await screen.findByText('Cancel Token');
await user.click(screen.getByTitle('Delete Token')); await user.click(screen.getByTitle('Delete Token'));
await screen.findByRole('button', { name: /^Cancel$/i }); await screen.findByRole('button', { name: /^Cancel$/i });
@@ -319,6 +341,7 @@ describe('IntegrationsTab', () => {
enableMcp(); enableMcp();
render(<IntegrationsTab />); render(<IntegrationsTab />);
await screen.findByText('MCP Configuration'); 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 user.click(screen.getByRole('button', { name: /Create New Token/i }));
await screen.findByText('Create API Token'); await screen.findByText('Create API Token');
const input = screen.getByPlaceholderText(/Claude Desktop/i); const input = screen.getByPlaceholderText(/Claude Desktop/i);
@@ -328,4 +351,32 @@ describe('IntegrationsTab', () => {
expect(postCalled).toBe(true); 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(<IntegrationsTab />);
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(<IntegrationsTab />);
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();
});
}); });
+207 -205
View File
@@ -2,11 +2,12 @@ import Section from './Section'
import React, { useEffect, useState } from 'react' import React, { useEffect, useState } from 'react'
import { useTranslation } from '../../i18n' import { useTranslation } from '../../i18n'
import { useToast } from '../shared/Toast' 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 { authApi, oauthApi } from '../../api/client'
import { useAddonStore } from '../../store/addonStore' import { useAddonStore } from '../../store/addonStore'
import PhotoProvidersSection from './PhotoProvidersSection' import PhotoProvidersSection from './PhotoProvidersSection'
import { getScopesByGroup, ALL_SCOPES } from '../../api/oauthScopes' import { ALL_SCOPES } from '../../api/oauthScopes'
import ScopeGroupPicker from '../OAuth/ScopeGroupPicker'
interface OAuthPreset { interface OAuthPreset {
id: string id: string
@@ -114,9 +115,14 @@ export default function IntegrationsTab(): React.ReactElement {
const [oauthRotateId, setOauthRotateId] = useState<string | null>(null) const [oauthRotateId, setOauthRotateId] = useState<string | null>(null)
const [oauthRotatedSecret, setOauthRotatedSecret] = useState<string | null>(null) const [oauthRotatedSecret, setOauthRotatedSecret] = useState<string | null>(null)
const [oauthRotating, setOauthRotating] = useState(false) const [oauthRotating, setOauthRotating] = useState(false)
const [oauthScopesOpen, setOauthScopesOpen] = useState<Record<string, boolean>>({}) // oauthScopesOpen is managed internally by ScopeGroupPicker
const [oauthScopesExpanded, setOauthScopesExpanded] = useState<Record<string, boolean>>({}) const [oauthScopesExpanded, setOauthScopesExpanded] = useState<Record<string, boolean>>({})
// MCP sub-tab state
const [activeMcpTab, setActiveMcpTab] = useState<'oauth' | 'apitokens'>('oauth')
const [configOpenOAuth, setConfigOpenOAuth] = useState(false)
const [configOpenToken, setConfigOpenToken] = useState(false)
// MCP state // MCP state
const [mcpTokens, setMcpTokens] = useState<McpToken[]>([]) const [mcpTokens, setMcpTokens] = useState<McpToken[]>([])
const [mcpModalOpen, setMcpModalOpen] = useState(false) const [mcpModalOpen, setMcpModalOpen] = useState(false)
@@ -127,6 +133,19 @@ export default function IntegrationsTab(): React.ReactElement {
const [copiedKey, setCopiedKey] = useState<string | null>(null) const [copiedKey, setCopiedKey] = useState<string | null>(null)
const mcpEndpoint = `${window.location.origin}/mcp` const mcpEndpoint = `${window.location.origin}/mcp`
const mcpJsonConfigOAuth = `{
"mcpServers": {
"trek": {
"command": "npx",
"args": [
"mcp-remote",
"${mcpEndpoint}",
"--static-oauth-client-info",
"{\\"client_id\\": \\"<your_client_id>\\", \\"client_secret\\": \\"<your_client_secret>\\"}"
]
}
}
}`
const mcpJsonConfig = `{ const mcpJsonConfig = `{
"mcpServers": { "mcpServers": {
"trek": { "trek": {
@@ -241,8 +260,6 @@ export default function IntegrationsTab(): React.ReactElement {
} }
} }
const scopesByGroup = getScopesByGroup()
return ( return (
<> <>
<PhotoProvidersSection /> <PhotoProvidersSection />
@@ -263,113 +280,193 @@ export default function IntegrationsTab(): React.ReactElement {
</div> </div>
</div> </div>
{/* JSON config box */} {/* Sub-tab bar */}
<div> <div className="flex gap-1 rounded-lg p-1" style={{ background: 'var(--bg-secondary)', border: '1px solid var(--border-primary)' }}>
<div className="flex items-center justify-between mb-1.5"> <button
<label className="block text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>{t('settings.mcp.clientConfig')}</label> onClick={() => setActiveMcpTab('oauth')}
<button onClick={() => handleCopy(mcpJsonConfig, 'json')} className={`flex-1 px-3 py-1.5 text-sm font-medium rounded-md transition-colors ${
className="flex items-center gap-1.5 px-2.5 py-1 rounded text-xs border transition-colors hover:bg-slate-100 dark:hover:bg-slate-700" activeMcpTab === 'oauth' ? 'bg-slate-900 text-white' : 'text-slate-600 hover:text-slate-900 hover:bg-slate-50'
style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}> }`}>
{copiedKey === 'json' ? <Check className="w-3 h-3 text-green-500" /> : <Copy className="w-3 h-3" />} {t('settings.oauth.clients')}
{copiedKey === 'json' ? t('settings.mcp.copied') : t('settings.mcp.copy')} </button>
</button> <button
</div> onClick={() => setActiveMcpTab('apitokens')}
<pre className="p-3 rounded-lg text-xs font-mono overflow-x-auto border" style={{ background: 'var(--bg-secondary)', borderColor: 'var(--border-primary)', color: 'var(--text-primary)' }}> className={`flex-1 px-3 py-1.5 text-sm font-medium rounded-md transition-colors flex items-center justify-center gap-2 ${
{mcpJsonConfig} activeMcpTab === 'apitokens' ? 'bg-slate-900 text-white' : 'text-slate-600 hover:text-slate-900 hover:bg-slate-50'
</pre> }`}>
<p className="mt-1.5 text-xs" style={{ color: 'var(--text-tertiary)' }}>{t('settings.mcp.clientConfigHint')}</p> {t('settings.mcp.apiTokens')}
<span className="px-1.5 py-0.5 rounded text-[10px] font-medium"
style={{ background: 'rgba(245,158,11,0.15)', color: '#b45309', border: '1px solid rgba(245,158,11,0.4)' }}>
Deprecated
</span>
</button>
</div> </div>
{/* OAuth Clients */} {/* OAuth 2.1 Clients tab */}
<div> {activeMcpTab === 'oauth' && (
<div className="flex items-center gap-2 mb-3"> <>
<ShieldCheck className="w-4 h-4" style={{ color: 'var(--text-secondary)' }} /> {/* JSON config — OAuth (collapsible) */}
<label className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>{t('settings.oauth.clients')}</label>
</div>
<p className="text-xs mb-3" style={{ color: 'var(--text-tertiary)' }}>{t('settings.oauth.clientsHint')}</p>
<div className="flex justify-end mb-2">
<button onClick={() => { setOauthCreateOpen(true); setOauthCreatedClient(null); setOauthNewName(''); setOauthNewUris(''); setOauthNewScopes([]) }}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium transition-colors"
style={{ background: 'var(--accent-primary, #4f46e5)', color: '#fff' }}>
<Plus className="w-3.5 h-3.5" /> {t('settings.oauth.createClient')}
</button>
</div>
{oauthClients.length === 0 ? (
<p className="text-sm py-3 text-center rounded-lg border" style={{ color: 'var(--text-tertiary)', borderColor: 'var(--border-primary)' }}>
{t('settings.oauth.noClients')}
</p>
) : (
<div className="rounded-lg border overflow-hidden" style={{ borderColor: 'var(--border-primary)' }}> <div className="rounded-lg border overflow-hidden" style={{ borderColor: 'var(--border-primary)' }}>
{oauthClients.map((client, i) => ( <button
<div key={client.id} className="px-4 py-3" onClick={() => setConfigOpenOAuth(o => !o)}
style={{ borderBottom: i < oauthClients.length - 1 ? '1px solid var(--border-primary)' : undefined }}> className="w-full flex items-center justify-between px-3 py-2.5 transition-colors hover:bg-slate-50 dark:hover:bg-slate-800"
<div className="flex items-center gap-3"> style={{ background: 'var(--bg-secondary)' }}>
<KeyRound className="w-4 h-4 flex-shrink-0" style={{ color: 'var(--text-tertiary)' }} /> <span className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>{t('settings.mcp.clientConfig')}</span>
<div className="flex-1 min-w-0"> {configOpenOAuth ? <ChevronDown className="w-4 h-4" style={{ color: 'var(--text-tertiary)' }} /> : <ChevronRight className="w-4 h-4" style={{ color: 'var(--text-tertiary)' }} />}
<p className="text-sm font-medium truncate" style={{ color: 'var(--text-primary)' }}>{client.name}</p> </button>
<p className="text-xs font-mono mt-0.5" style={{ color: 'var(--text-tertiary)' }}> {configOpenOAuth && (
{t('settings.oauth.clientId')}: {client.client_id} <div className="p-3 border-t" style={{ borderColor: 'var(--border-primary)' }}>
<span className="ml-3 font-sans">{t('settings.mcp.tokenCreatedAt')} {new Date(client.created_at).toLocaleDateString(locale)}</span> <div className="flex justify-end mb-1.5">
</p> <button onClick={() => handleCopy(mcpJsonConfigOAuth, 'json-oauth')}
<div className="flex flex-wrap gap-1 mt-1.5"> className="flex items-center gap-1.5 px-2.5 py-1 rounded text-xs border transition-colors hover:bg-slate-100 dark:hover:bg-slate-700"
{(oauthScopesExpanded[client.id] ? client.allowed_scopes : client.allowed_scopes.slice(0, 5)).map(s => ( style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}>
<span key={s} className="px-1.5 py-0.5 rounded text-xs" style={{ background: 'var(--bg-secondary)', color: 'var(--text-tertiary)', border: '1px solid var(--border-primary)' }}>{s}</span> {copiedKey === 'json-oauth' ? <Check className="w-3 h-3 text-green-500" /> : <Copy className="w-3 h-3" />}
))} {copiedKey === 'json-oauth' ? t('settings.mcp.copied') : t('settings.mcp.copy')}
{client.allowed_scopes.length > 5 && (
<button
onClick={() => setOauthScopesExpanded(prev => ({ ...prev, [client.id]: !prev[client.id] }))}
className="px-1.5 py-0.5 rounded text-xs transition-colors hover:bg-slate-100 dark:hover:bg-slate-700"
style={{ color: 'var(--text-tertiary)', border: '1px solid var(--border-primary)' }}>
{oauthScopesExpanded[client.id] ? '' : `+${client.allowed_scopes.length - 5}`}
</button>
)}
</div>
</div>
<button onClick={() => setOauthRotateId(client.id)}
className="p-1.5 rounded-lg transition-colors hover:bg-amber-50 hover:text-amber-600 dark:hover:bg-amber-900/20"
style={{ color: 'var(--text-tertiary)' }} title={t('settings.oauth.rotateSecret')}>
<RefreshCw className="w-4 h-4" />
</button>
<button onClick={() => setOauthDeleteId(client.id)}
className="p-1.5 rounded-lg transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20"
style={{ color: 'var(--text-tertiary)' }} title={t('settings.oauth.deleteClient')}>
<Trash2 className="w-4 h-4" />
</button> </button>
</div> </div>
<pre className="p-3 rounded-lg text-xs font-mono overflow-x-auto border" style={{ background: 'var(--bg-secondary)', borderColor: 'var(--border-primary)', color: 'var(--text-primary)' }}>
{mcpJsonConfigOAuth}
</pre>
<p className="mt-1.5 text-xs" style={{ color: 'var(--text-tertiary)' }}>{t('settings.mcp.clientConfigHintOAuth')}</p>
</div> </div>
))} )}
</div> </div>
)}
</div>
{/* Token list — deprecated */} <div>
<div className="rounded-lg border" style={{ borderColor: 'var(--border-primary)' }}> <p className="text-xs mb-3" style={{ color: 'var(--text-tertiary)' }}>{t('settings.oauth.clientsHint')}</p>
<div className="flex items-center justify-between px-4 py-3 border-b" style={{ borderColor: 'var(--border-primary)', background: 'rgba(245,158,11,0.06)' }}>
<div className="flex items-center gap-2"> <div className="flex justify-end mb-2">
<label className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>{t('settings.mcp.apiTokens')}</label> <button onClick={() => { setOauthCreateOpen(true); setOauthCreatedClient(null); setOauthNewName(''); setOauthNewUris(''); setOauthNewScopes([]) }}
<span className="px-1.5 py-0.5 rounded text-xs font-medium" style={{ background: 'rgba(245,158,11,0.15)', color: '#b45309', border: '1px solid rgba(245,158,11,0.4)' }}> className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium transition-colors bg-slate-900 text-white hover:bg-slate-700">
Deprecated <Plus className="w-3.5 h-3.5" /> {t('settings.oauth.createClient')}
</span> </button>
</div>
{oauthClients.length === 0 ? (
<p className="text-sm py-3 text-center rounded-lg border" style={{ color: 'var(--text-tertiary)', borderColor: 'var(--border-primary)' }}>
{t('settings.oauth.noClients')}
</p>
) : (
<div className="rounded-lg border overflow-hidden" style={{ borderColor: 'var(--border-primary)' }}>
{oauthClients.map((client, i) => (
<div key={client.id} className="px-4 py-3"
style={{ borderBottom: i < oauthClients.length - 1 ? '1px solid var(--border-primary)' : undefined }}>
<div className="flex items-center gap-3">
<KeyRound className="w-4 h-4 flex-shrink-0" style={{ color: 'var(--text-tertiary)' }} />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate" style={{ color: 'var(--text-primary)' }}>{client.name}</p>
<p className="text-xs font-mono mt-0.5" style={{ color: 'var(--text-tertiary)' }}>
{t('settings.oauth.clientId')}: {client.client_id}
<span className="ml-3 font-sans">{t('settings.mcp.tokenCreatedAt')} {new Date(client.created_at).toLocaleDateString(locale)}</span>
</p>
<div className="flex flex-wrap gap-1 mt-1.5">
{(oauthScopesExpanded[client.id] ? client.allowed_scopes : client.allowed_scopes.slice(0, 5)).map(s => (
<span key={s} className="px-1.5 py-0.5 rounded text-xs" style={{ background: 'var(--bg-secondary)', color: 'var(--text-tertiary)', border: '1px solid var(--border-primary)' }}>{s}</span>
))}
{client.allowed_scopes.length > 5 && (
<button
onClick={() => setOauthScopesExpanded(prev => ({ ...prev, [client.id]: !prev[client.id] }))}
className="px-1.5 py-0.5 rounded text-xs transition-colors hover:bg-slate-100 dark:hover:bg-slate-700"
style={{ color: 'var(--text-tertiary)', border: '1px solid var(--border-primary)' }}>
{oauthScopesExpanded[client.id] ? '' : `+${client.allowed_scopes.length - 5}`}
</button>
)}
</div>
</div>
<button onClick={() => setOauthRotateId(client.id)}
className="p-1.5 rounded-lg transition-colors hover:bg-amber-50 hover:text-amber-600 dark:hover:bg-amber-900/20"
style={{ color: 'var(--text-tertiary)' }} title={t('settings.oauth.rotateSecret')}>
<RefreshCw className="w-4 h-4" />
</button>
<button onClick={() => setOauthDeleteId(client.id)}
className="p-1.5 rounded-lg transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20"
style={{ color: 'var(--text-tertiary)' }} title={t('settings.oauth.deleteClient')}>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
))}
</div>
)}
</div> </div>
<button onClick={() => { setMcpModalOpen(true); setMcpCreatedToken(null); setMcpNewName('') }}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium transition-colors opacity-60" {/* Active OAuth Sessions */}
style={{ background: 'var(--bg-tertiary, #e5e7eb)', color: 'var(--text-secondary)' }}> {oauthSessions.length > 0 && (
<Plus className="w-3.5 h-3.5" /> {t('settings.mcp.createToken')} <div>
</button> <label className="text-sm font-medium block mb-2" style={{ color: 'var(--text-secondary)' }}>{t('settings.oauth.activeSessions')}</label>
</div> <div className="rounded-lg border overflow-hidden" style={{ borderColor: 'var(--border-primary)' }}>
<div className="px-4 py-2.5 flex items-start gap-2 border-b" style={{ borderColor: 'var(--border-primary)', background: 'rgba(245,158,11,0.06)' }}> {oauthSessions.map((session, i) => (
<span className="text-amber-500 mt-0.5 flex-shrink-0"></span> <div key={session.id} className="flex items-center gap-3 px-4 py-3"
<p className="text-xs" style={{ color: '#92400e' }}>{t('settings.mcp.apiTokensDeprecated')}</p> style={{ borderBottom: i < oauthSessions.length - 1 ? '1px solid var(--border-primary)' : undefined }}>
</div> <div className="flex-1 min-w-0">
<div className="p-4"> <p className="text-sm font-medium truncate" style={{ color: 'var(--text-primary)' }}>{session.client_name}</p>
<p className="text-xs mt-0.5" style={{ color: 'var(--text-tertiary)' }}>
{t('settings.oauth.sessionScopes')}: {session.scopes.join(', ')}
<span className="ml-3">{t('settings.oauth.sessionExpires')} {new Date(session.access_token_expires_at).toLocaleDateString(locale)}</span>
</p>
</div>
<button onClick={() => setOauthRevokeId(session.id)}
className="px-2.5 py-1 rounded text-xs border transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20"
style={{ borderColor: 'var(--border-primary)', color: 'var(--text-tertiary)' }}>
{t('settings.oauth.revoke')}
</button>
</div>
))}
</div>
</div>
)}
</>
)}
{/* API Tokens tab (deprecated) */}
{activeMcpTab === 'apitokens' && (
<>
<div className="flex items-baseline gap-2 px-3 py-2.5 rounded-lg" style={{ background: 'rgba(245,158,11,0.06)', border: '1px solid rgba(245,158,11,0.3)' }}>
<span className="text-amber-500 flex-shrink-0 leading-none"></span>
<p className="text-xs" style={{ color: '#92400e' }}>{t('settings.mcp.apiTokensDeprecated')}</p>
</div>
{/* JSON config — API Token (collapsible) */}
<div className="rounded-lg border overflow-hidden" style={{ borderColor: 'var(--border-primary)' }}>
<button
onClick={() => setConfigOpenToken(o => !o)}
className="w-full flex items-center justify-between px-3 py-2.5 transition-colors hover:bg-slate-50 dark:hover:bg-slate-800"
style={{ background: 'var(--bg-secondary)' }}>
<span className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>{t('settings.mcp.clientConfig')}</span>
{configOpenToken ? <ChevronDown className="w-4 h-4" style={{ color: 'var(--text-tertiary)' }} /> : <ChevronRight className="w-4 h-4" style={{ color: 'var(--text-tertiary)' }} />}
</button>
{configOpenToken && (
<div className="p-3 border-t" style={{ borderColor: 'var(--border-primary)' }}>
<div className="flex justify-end mb-1.5">
<button onClick={() => handleCopy(mcpJsonConfig, 'json-token')}
className="flex items-center gap-1.5 px-2.5 py-1 rounded text-xs border transition-colors hover:bg-slate-100 dark:hover:bg-slate-700"
style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}>
{copiedKey === 'json-token' ? <Check className="w-3 h-3 text-green-500" /> : <Copy className="w-3 h-3" />}
{copiedKey === 'json-token' ? t('settings.mcp.copied') : t('settings.mcp.copy')}
</button>
</div>
<pre className="p-3 rounded-lg text-xs font-mono overflow-x-auto border" style={{ background: 'var(--bg-secondary)', borderColor: 'var(--border-primary)', color: 'var(--text-primary)' }}>
{mcpJsonConfig}
</pre>
<p className="mt-1.5 text-xs" style={{ color: 'var(--text-tertiary)' }}>{t('settings.mcp.clientConfigHint')}</p>
</div>
)}
</div>
<div className="flex justify-end">
<button onClick={() => { setMcpModalOpen(true); setMcpCreatedToken(null); setMcpNewName('') }}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium transition-colors opacity-60"
style={{ background: 'var(--bg-tertiary, #e5e7eb)', color: 'var(--text-secondary)' }}>
<Plus className="w-3.5 h-3.5" /> {t('settings.mcp.createToken')}
</button>
</div>
{mcpTokens.length === 0 ? ( {mcpTokens.length === 0 ? (
<p className="text-sm py-2 text-center" style={{ color: 'var(--text-tertiary)' }}> <p className="text-sm py-2 text-center" style={{ color: 'var(--text-tertiary)' }}>
{t('settings.mcp.noTokens')} {t('settings.mcp.noTokens')}
</p> </p>
) : ( ) : (
<div className="space-y-0 rounded-lg border overflow-hidden" style={{ borderColor: 'var(--border-primary)' }}> <div className="rounded-lg border overflow-hidden" style={{ borderColor: 'var(--border-primary)' }}>
{mcpTokens.map((token, i) => ( {mcpTokens.map((token, i) => (
<div key={token.id} className="flex items-center gap-3 px-4 py-3" <div key={token.id} className="flex items-center gap-3 px-4 py-3"
style={{ borderBottom: i < mcpTokens.length - 1 ? '1px solid var(--border-primary)' : undefined }}> style={{ borderBottom: i < mcpTokens.length - 1 ? '1px solid var(--border-primary)' : undefined }}>
@@ -392,33 +489,7 @@ export default function IntegrationsTab(): React.ReactElement {
))} ))}
</div> </div>
)} )}
</div> </>
</div>
{/* Active OAuth Sessions */}
{oauthSessions.length > 0 && (
<div>
<label className="text-sm font-medium block mb-2" style={{ color: 'var(--text-secondary)' }}>{t('settings.oauth.activeSessions')}</label>
<div className="rounded-lg border overflow-hidden" style={{ borderColor: 'var(--border-primary)' }}>
{oauthSessions.map((session, i) => (
<div key={session.id} className="flex items-center gap-3 px-4 py-3"
style={{ borderBottom: i < oauthSessions.length - 1 ? '1px solid var(--border-primary)' : undefined }}>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate" style={{ color: 'var(--text-primary)' }}>{session.client_name}</p>
<p className="text-xs mt-0.5" style={{ color: 'var(--text-tertiary)' }}>
{t('settings.oauth.sessionScopes')}: {session.scopes.join(', ')}
<span className="ml-3">{t('settings.oauth.sessionExpires')} {new Date(session.access_token_expires_at).toLocaleDateString(locale)}</span>
</p>
</div>
<button onClick={() => setOauthRevokeId(session.id)}
className="px-2.5 py-1 rounded text-xs border transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20"
style={{ borderColor: 'var(--border-primary)', color: 'var(--text-tertiary)' }}>
{t('settings.oauth.revoke')}
</button>
</div>
))}
</div>
</div>
)} )}
</Section> </Section>
)} )}
@@ -436,7 +507,7 @@ export default function IntegrationsTab(): React.ReactElement {
<input type="text" value={mcpNewName} onChange={e => setMcpNewName(e.target.value)} <input type="text" value={mcpNewName} onChange={e => setMcpNewName(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleCreateMcpToken()} onKeyDown={e => e.key === 'Enter' && handleCreateMcpToken()}
placeholder={t('settings.mcp.modal.tokenNamePlaceholder')} 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)' }} style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-secondary)', color: 'var(--text-primary)' }}
autoFocus /> autoFocus />
</div> </div>
@@ -446,8 +517,7 @@ export default function IntegrationsTab(): React.ReactElement {
{t('common.cancel')} {t('common.cancel')}
</button> </button>
<button onClick={handleCreateMcpToken} disabled={!mcpNewName.trim() || mcpCreating} <button onClick={handleCreateMcpToken} disabled={!mcpNewName.trim() || mcpCreating}
className="px-4 py-2 rounded-lg text-sm font-medium text-white disabled:opacity-50" className="px-4 py-2 rounded-lg text-sm font-medium text-white bg-slate-900 hover:bg-slate-700 disabled:opacity-50">
style={{ background: 'var(--accent-primary, #4f46e5)' }}>
{mcpCreating ? t('settings.mcp.modal.creating') : t('settings.mcp.modal.create')} {mcpCreating ? t('settings.mcp.modal.creating') : t('settings.mcp.modal.create')}
</button> </button>
</div> </div>
@@ -471,8 +541,7 @@ export default function IntegrationsTab(): React.ReactElement {
</div> </div>
<div className="flex justify-end"> <div className="flex justify-end">
<button onClick={() => { setMcpModalOpen(false); setMcpCreatedToken(null) }} <button onClick={() => { setMcpModalOpen(false); setMcpCreatedToken(null) }}
className="px-4 py-2 rounded-lg text-sm font-medium text-white" className="px-4 py-2 rounded-lg text-sm font-medium text-white bg-slate-900 hover:bg-slate-700">
style={{ background: 'var(--accent-primary, #4f46e5)' }}>
{t('settings.mcp.modal.done')} {t('settings.mcp.modal.done')}
</button> </button>
</div> </div>
@@ -536,7 +605,7 @@ export default function IntegrationsTab(): React.ReactElement {
<label className="block text-sm font-medium mb-1.5" style={{ color: 'var(--text-secondary)' }}>{t('settings.oauth.modal.clientName')}</label> <label className="block text-sm font-medium mb-1.5" style={{ color: 'var(--text-secondary)' }}>{t('settings.oauth.modal.clientName')}</label>
<input type="text" value={oauthNewName} onChange={e => setOauthNewName(e.target.value)} <input type="text" value={oauthNewName} onChange={e => setOauthNewName(e.target.value)}
placeholder={t('settings.oauth.modal.clientNamePlaceholder')} 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)' }} style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-secondary)', color: 'var(--text-primary)' }}
autoFocus /> autoFocus />
</div> </div>
@@ -546,78 +615,15 @@ export default function IntegrationsTab(): React.ReactElement {
<textarea value={oauthNewUris} onChange={e => setOauthNewUris(e.target.value)} <textarea value={oauthNewUris} onChange={e => setOauthNewUris(e.target.value)}
placeholder={t('settings.oauth.modal.redirectUrisPlaceholder')} placeholder={t('settings.oauth.modal.redirectUrisPlaceholder')}
rows={3} rows={3}
className="w-full px-3 py-2.5 border rounded-lg text-sm font-mono resize-none focus:outline-none focus:ring-2 focus:ring-indigo-300" className="w-full px-3 py-2.5 border rounded-lg text-sm font-mono resize-none focus:outline-none focus:ring-2 focus:ring-slate-400"
style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-secondary)', color: 'var(--text-primary)' }} /> style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-secondary)', color: 'var(--text-primary)' }} />
<p className="mt-1 text-xs" style={{ color: 'var(--text-tertiary)' }}>{t('settings.oauth.modal.redirectUrisHint')}</p> <p className="mt-1 text-xs" style={{ color: 'var(--text-tertiary)' }}>{t('settings.oauth.modal.redirectUrisHint')}</p>
</div> </div>
<div> <div>
<div className="flex items-center justify-between mb-1"> <label className="block text-sm font-medium mb-1" style={{ color: 'var(--text-secondary)' }}>{t('settings.oauth.modal.scopes')}</label>
<label className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>{t('settings.oauth.modal.scopes')}</label>
<button type="button"
onClick={() => {
const allScopes = Object.values(scopesByGroup).flat().map(s => s.scope)
const allSelected = allScopes.every(s => oauthNewScopes.includes(s))
setOauthNewScopes(allSelected ? [] : allScopes)
}}
className="text-xs px-2 py-0.5 rounded border transition-colors hover:bg-slate-100 dark:hover:bg-slate-700"
style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}>
{Object.values(scopesByGroup).flat().every(s => oauthNewScopes.includes(s.scope))
? t('settings.oauth.modal.deselectAll')
: t('settings.oauth.modal.selectAll')}
</button>
</div>
<p className="text-xs mb-2" style={{ color: 'var(--text-tertiary)' }}>{t('settings.oauth.modal.scopesHint')}</p> <p className="text-xs mb-2" style={{ color: 'var(--text-tertiary)' }}>{t('settings.oauth.modal.scopesHint')}</p>
<div className="space-y-1 max-h-56 overflow-y-auto pr-1"> <ScopeGroupPicker selected={oauthNewScopes} onChange={setOauthNewScopes} />
{Object.entries(scopesByGroup).map(([group, groupScopes]) => {
const groupScopeKeys = groupScopes.map(s => s.scope)
const allGroupSelected = groupScopeKeys.every(s => oauthNewScopes.includes(s))
const someGroupSelected = groupScopeKeys.some(s => oauthNewScopes.includes(s))
return (
<div key={group} className="rounded-lg border overflow-hidden" style={{ borderColor: 'var(--border-primary)' }}>
<div className="flex items-center gap-1 px-3 py-2" style={{ background: 'var(--bg-secondary)' }}>
<button type="button"
onClick={() => setOauthScopesOpen(prev => ({ ...prev, [group]: !prev[group] }))}
className="flex items-center gap-1 flex-1 text-xs font-semibold hover:opacity-70 transition-opacity text-left"
style={{ color: 'var(--text-secondary)' }}>
{oauthScopesOpen[group] ? <ChevronDown className="w-3 h-3 flex-shrink-0" /> : <ChevronRight className="w-3 h-3 flex-shrink-0" />}
{group}
{someGroupSelected && (
<span className="ml-1.5 text-xs font-normal" style={{ color: 'var(--text-tertiary)' }}>
({groupScopeKeys.filter(s => oauthNewScopes.includes(s)).length}/{groupScopeKeys.length})
</span>
)}
</button>
<input type="checkbox"
checked={allGroupSelected}
ref={el => { if (el) el.indeterminate = someGroupSelected && !allGroupSelected }}
onChange={e => setOauthNewScopes(prev =>
e.target.checked
? [...new Set([...prev, ...groupScopeKeys])]
: prev.filter(s => !groupScopeKeys.includes(s))
)}
className="rounded" title={allGroupSelected ? `Deselect all ${group}` : `Select all ${group}`} />
</div>
{oauthScopesOpen[group] && (
<div className="divide-y" style={{ borderColor: 'var(--border-primary)' }}>
{groupScopes.map(({ scope, label, description }) => (
<label key={scope} className="flex items-start gap-2.5 px-3 py-2 cursor-pointer hover:bg-slate-50 dark:hover:bg-slate-800/50 transition-colors">
<input type="checkbox"
checked={oauthNewScopes.includes(scope)}
onChange={e => setOauthNewScopes(prev => e.target.checked ? [...prev, scope] : prev.filter(s => s !== scope))}
className="mt-0.5 rounded flex-shrink-0" />
<div>
<p className="text-xs font-medium" style={{ color: 'var(--text-primary)' }}>{label}</p>
<p className="text-xs" style={{ color: 'var(--text-tertiary)' }}>{description}</p>
</div>
</label>
))}
</div>
)}
</div>
)
})}
</div>
</div> </div>
<div className="flex gap-2 justify-end pt-1"> <div className="flex gap-2 justify-end pt-1">
@@ -627,8 +633,7 @@ export default function IntegrationsTab(): React.ReactElement {
</button> </button>
<button onClick={handleCreateOAuthClient} <button onClick={handleCreateOAuthClient}
disabled={!oauthNewName.trim() || !oauthNewUris.trim() || oauthCreating} disabled={!oauthNewName.trim() || !oauthNewUris.trim() || oauthCreating}
className="px-4 py-2 rounded-lg text-sm font-medium text-white disabled:opacity-50" className="px-4 py-2 rounded-lg text-sm font-medium text-white bg-slate-900 hover:bg-slate-700 disabled:opacity-50">
style={{ background: 'var(--accent-primary, #4f46e5)' }}>
{oauthCreating ? t('settings.oauth.modal.creating') : t('settings.oauth.modal.create')} {oauthCreating ? t('settings.oauth.modal.creating') : t('settings.oauth.modal.create')}
</button> </button>
</div> </div>
@@ -672,8 +677,7 @@ export default function IntegrationsTab(): React.ReactElement {
<div className="flex justify-end"> <div className="flex justify-end">
<button onClick={() => { setOauthCreateOpen(false); setOauthCreatedClient(null) }} <button onClick={() => { setOauthCreateOpen(false); setOauthCreatedClient(null) }}
className="px-4 py-2 rounded-lg text-sm font-medium text-white" className="px-4 py-2 rounded-lg text-sm font-medium text-white bg-slate-900 hover:bg-slate-700">
style={{ background: 'var(--accent-primary, #4f46e5)' }}>
{t('settings.mcp.modal.done')} {t('settings.mcp.modal.done')}
</button> </button>
</div> </div>
@@ -717,8 +721,7 @@ export default function IntegrationsTab(): React.ReactElement {
{t('common.cancel')} {t('common.cancel')}
</button> </button>
<button onClick={() => handleRotateSecret(oauthRotateId)} disabled={oauthRotating} <button onClick={() => handleRotateSecret(oauthRotateId)} disabled={oauthRotating}
className="px-4 py-2 rounded-lg text-sm font-medium text-white disabled:opacity-50" className="px-4 py-2 rounded-lg text-sm font-medium text-white bg-slate-900 hover:bg-slate-700 disabled:opacity-50">
style={{ background: 'var(--accent-primary, #4f46e5)' }}>
{oauthRotating ? t('settings.oauth.rotateSecretConfirming') : t('settings.oauth.rotateSecretConfirm')} {oauthRotating ? t('settings.oauth.rotateSecretConfirming') : t('settings.oauth.rotateSecretConfirm')}
</button> </button>
</div> </div>
@@ -750,8 +753,7 @@ export default function IntegrationsTab(): React.ReactElement {
</div> </div>
<div className="flex justify-end"> <div className="flex justify-end">
<button onClick={() => setOauthRotatedSecret(null)} <button onClick={() => setOauthRotatedSecret(null)}
className="px-4 py-2 rounded-lg text-sm font-medium text-white" className="px-4 py-2 rounded-lg text-sm font-medium text-white bg-slate-900 hover:bg-slate-700">
style={{ background: 'var(--accent-primary, #4f46e5)' }}>
{t('settings.mcp.modal.done')} {t('settings.mcp.modal.done')}
</button> </button>
</div> </div>
+1
View File
@@ -233,6 +233,7 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'settings.mcp.endpoint': 'نقطة نهاية MCP', 'settings.mcp.endpoint': 'نقطة نهاية MCP',
'settings.mcp.clientConfig': 'إعداد العميل', 'settings.mcp.clientConfig': 'إعداد العميل',
'settings.mcp.clientConfigHint': 'استبدل <your_token> برمز API من القائمة أدناه. قد يحتاج مسار npx إلى ضبط وفق نظامك (مثلاً C:\\PROGRA~1\\nodejs\\npx.cmd على Windows).', 'settings.mcp.clientConfigHint': 'استبدل <your_token> برمز API من القائمة أدناه. قد يحتاج مسار npx إلى ضبط وفق نظامك (مثلاً C:\\PROGRA~1\\nodejs\\npx.cmd على Windows).',
'settings.mcp.clientConfigHintOAuth': 'Replace <your_client_id> and <your_client_secret> with the credentials shown in the OAuth 2.1 client you created above. mcp-remote will open your browser to complete the authorization the first time you connect. The path to npx may need to be adjusted for your system (e.g. C:\PROGRA~1\nodejs\npx.cmd on Windows).',
'settings.mcp.copy': 'نسخ', 'settings.mcp.copy': 'نسخ',
'settings.mcp.copied': 'تم النسخ!', 'settings.mcp.copied': 'تم النسخ!',
'settings.mcp.apiTokens': 'رموز API', 'settings.mcp.apiTokens': 'رموز API',
+1
View File
@@ -295,6 +295,7 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'settings.mcp.endpoint': 'Endpoint MCP', 'settings.mcp.endpoint': 'Endpoint MCP',
'settings.mcp.clientConfig': 'Configuração do cliente', 'settings.mcp.clientConfig': 'Configuração do cliente',
'settings.mcp.clientConfigHint': 'Substitua <your_token> por um token de API da lista abaixo. O caminho para o npx pode precisar ser ajustado para o seu sistema (ex.: C:\\PROGRA~1\\nodejs\\npx.cmd no Windows).', 'settings.mcp.clientConfigHint': 'Substitua <your_token> por um token de API da lista abaixo. O caminho para o npx pode precisar ser ajustado para o seu sistema (ex.: C:\\PROGRA~1\\nodejs\\npx.cmd no Windows).',
'settings.mcp.clientConfigHintOAuth': 'Replace <your_client_id> and <your_client_secret> with the credentials shown in the OAuth 2.1 client you created above. mcp-remote will open your browser to complete the authorization the first time you connect. The path to npx may need to be adjusted for your system (e.g. C:\PROGRA~1\nodejs\npx.cmd on Windows).',
'settings.mcp.copy': 'Copiar', 'settings.mcp.copy': 'Copiar',
'settings.mcp.copied': 'Copiado!', 'settings.mcp.copied': 'Copiado!',
'settings.mcp.apiTokens': 'Tokens de API', 'settings.mcp.apiTokens': 'Tokens de API',
+1
View File
@@ -181,6 +181,7 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'settings.mcp.endpoint': 'MCP endpoint', 'settings.mcp.endpoint': 'MCP endpoint',
'settings.mcp.clientConfig': 'Konfigurace klienta', 'settings.mcp.clientConfig': 'Konfigurace klienta',
'settings.mcp.clientConfigHint': 'Nahraďte <your_token> API tokenem ze seznamu níže. Cestu k npx může být nutné upravit pro váš systém (např. C:\\PROGRA~1\\nodejs\\npx.cmd ve Windows).', 'settings.mcp.clientConfigHint': 'Nahraďte <your_token> API tokenem ze seznamu níže. Cestu k npx může být nutné upravit pro váš systém (např. C:\\PROGRA~1\\nodejs\\npx.cmd ve Windows).',
'settings.mcp.clientConfigHintOAuth': 'Replace <your_client_id> and <your_client_secret> with the credentials shown in the OAuth 2.1 client you created above. mcp-remote will open your browser to complete the authorization the first time you connect. The path to npx may need to be adjusted for your system (e.g. C:\PROGRA~1\nodejs\npx.cmd on Windows).',
'settings.mcp.copy': 'Kopírovat', 'settings.mcp.copy': 'Kopírovat',
'settings.mcp.copied': 'Zkopírováno!', 'settings.mcp.copied': 'Zkopírováno!',
'settings.mcp.apiTokens': 'API tokeny', 'settings.mcp.apiTokens': 'API tokeny',
+1
View File
@@ -228,6 +228,7 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'settings.mcp.endpoint': 'MCP-Endpunkt', 'settings.mcp.endpoint': 'MCP-Endpunkt',
'settings.mcp.clientConfig': 'Client-Konfiguration', 'settings.mcp.clientConfig': 'Client-Konfiguration',
'settings.mcp.clientConfigHint': 'Ersetze <your_token> durch ein API-Token aus der Liste unten. Der Pfad zu npx muss ggf. für dein System angepasst werden (z. B. C:\\PROGRA~1\\nodejs\\npx.cmd unter Windows).', 'settings.mcp.clientConfigHint': 'Ersetze <your_token> durch ein API-Token aus der Liste unten. Der Pfad zu npx muss ggf. für dein System angepasst werden (z. B. C:\\PROGRA~1\\nodejs\\npx.cmd unter Windows).',
'settings.mcp.clientConfigHintOAuth': 'Replace <your_client_id> and <your_client_secret> with the credentials shown in the OAuth 2.1 client you created above. mcp-remote will open your browser to complete the authorization the first time you connect. The path to npx may need to be adjusted for your system (e.g. C:\PROGRA~1\nodejs\npx.cmd on Windows).',
'settings.mcp.copy': 'Kopieren', 'settings.mcp.copy': 'Kopieren',
'settings.mcp.copied': 'Kopiert!', 'settings.mcp.copied': 'Kopiert!',
'settings.mcp.apiTokens': 'API-Tokens', 'settings.mcp.apiTokens': 'API-Tokens',
+1
View File
@@ -249,6 +249,7 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'settings.mcp.endpoint': 'MCP Endpoint', 'settings.mcp.endpoint': 'MCP Endpoint',
'settings.mcp.clientConfig': 'Client Configuration', 'settings.mcp.clientConfig': 'Client Configuration',
'settings.mcp.clientConfigHint': 'Replace <your_token> with an API token from the list below. The path to npx may need to be adjusted for your system (e.g. C:\\PROGRA~1\\nodejs\\npx.cmd on Windows).', 'settings.mcp.clientConfigHint': 'Replace <your_token> with an API token from the list below. The path to npx may need to be adjusted for your system (e.g. C:\\PROGRA~1\\nodejs\\npx.cmd on Windows).',
'settings.mcp.clientConfigHintOAuth': 'Replace <your_client_id> and <your_client_secret> with the credentials shown in the OAuth 2.1 client you created above. mcp-remote will open your browser to complete the authorization the first time you connect. The path to npx may need to be adjusted for your system (e.g. C:\PROGRA~1\nodejs\npx.cmd on Windows).',
'settings.mcp.copy': 'Copy', 'settings.mcp.copy': 'Copy',
'settings.mcp.copied': 'Copied!', 'settings.mcp.copied': 'Copied!',
'settings.mcp.apiTokens': 'API Tokens', 'settings.mcp.apiTokens': 'API Tokens',
+1
View File
@@ -229,6 +229,7 @@ const es: Record<string, string> = {
'settings.mcp.endpoint': 'Endpoint MCP', 'settings.mcp.endpoint': 'Endpoint MCP',
'settings.mcp.clientConfig': 'Configuración del cliente', 'settings.mcp.clientConfig': 'Configuración del cliente',
'settings.mcp.clientConfigHint': 'Reemplaza <your_token> con un token de la lista de abajo. Es posible que debas ajustar la ruta de npx según tu sistema (p. ej. C:\\PROGRA~1\\nodejs\\npx.cmd en Windows).', 'settings.mcp.clientConfigHint': 'Reemplaza <your_token> con un token de la lista de abajo. Es posible que debas ajustar la ruta de npx según tu sistema (p. ej. C:\\PROGRA~1\\nodejs\\npx.cmd en Windows).',
'settings.mcp.clientConfigHintOAuth': 'Replace <your_client_id> and <your_client_secret> with the credentials shown in the OAuth 2.1 client you created above. mcp-remote will open your browser to complete the authorization the first time you connect. The path to npx may need to be adjusted for your system (e.g. C:\PROGRA~1\nodejs\npx.cmd on Windows).',
'settings.mcp.copy': 'Copiar', 'settings.mcp.copy': 'Copiar',
'settings.mcp.copied': '¡Copiado!', 'settings.mcp.copied': '¡Copiado!',
'settings.mcp.apiTokens': 'Tokens de API', 'settings.mcp.apiTokens': 'Tokens de API',
+1
View File
@@ -228,6 +228,7 @@ const fr: Record<string, string> = {
'settings.mcp.endpoint': 'Point de terminaison MCP', 'settings.mcp.endpoint': 'Point de terminaison MCP',
'settings.mcp.clientConfig': 'Configuration du client', 'settings.mcp.clientConfig': 'Configuration du client',
'settings.mcp.clientConfigHint': 'Remplacez <your_token> par un token API de la liste ci-dessous. Le chemin vers npx devra peut-être être ajusté selon votre système (ex. C:\\PROGRA~1\\nodejs\\npx.cmd sous Windows).', 'settings.mcp.clientConfigHint': 'Remplacez <your_token> par un token API de la liste ci-dessous. Le chemin vers npx devra peut-être être ajusté selon votre système (ex. C:\\PROGRA~1\\nodejs\\npx.cmd sous Windows).',
'settings.mcp.clientConfigHintOAuth': 'Replace <your_client_id> and <your_client_secret> with the credentials shown in the OAuth 2.1 client you created above. mcp-remote will open your browser to complete the authorization the first time you connect. The path to npx may need to be adjusted for your system (e.g. C:\PROGRA~1\nodejs\npx.cmd on Windows).',
'settings.mcp.copy': 'Copier', 'settings.mcp.copy': 'Copier',
'settings.mcp.copied': 'Copié !', 'settings.mcp.copied': 'Copié !',
'settings.mcp.apiTokens': 'Tokens API', 'settings.mcp.apiTokens': 'Tokens API',
+1
View File
@@ -180,6 +180,7 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
'settings.mcp.endpoint': 'MCP végpont', 'settings.mcp.endpoint': 'MCP végpont',
'settings.mcp.clientConfig': 'Kliens konfiguráció', 'settings.mcp.clientConfig': 'Kliens konfiguráció',
'settings.mcp.clientConfigHint': 'Cserélje ki a <your_token> részt egy API tokenre az alábbi listából. Az npx elérési útját szükség lehet módosítani a rendszeréhez (pl. C:\\PROGRA~1\\nodejs\\npx.cmd Windows-on).', 'settings.mcp.clientConfigHint': 'Cserélje ki a <your_token> részt egy API tokenre az alábbi listából. Az npx elérési útját szükség lehet módosítani a rendszeréhez (pl. C:\\PROGRA~1\\nodejs\\npx.cmd Windows-on).',
'settings.mcp.clientConfigHintOAuth': 'Replace <your_client_id> and <your_client_secret> with the credentials shown in the OAuth 2.1 client you created above. mcp-remote will open your browser to complete the authorization the first time you connect. The path to npx may need to be adjusted for your system (e.g. C:\PROGRA~1\nodejs\npx.cmd on Windows).',
'settings.mcp.copy': 'Másolás', 'settings.mcp.copy': 'Másolás',
'settings.mcp.copied': 'Másolva!', 'settings.mcp.copied': 'Másolva!',
'settings.mcp.apiTokens': 'API tokenek', 'settings.mcp.apiTokens': 'API tokenek',
+1
View File
@@ -180,6 +180,7 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'settings.mcp.endpoint': 'Endpoint MCP', 'settings.mcp.endpoint': 'Endpoint MCP',
'settings.mcp.clientConfig': 'Configurazione client', 'settings.mcp.clientConfig': 'Configurazione client',
'settings.mcp.clientConfigHint': 'Sostituisci <your_token> con un token API dalla lista sottostante. Il percorso di npx potrebbe dover essere adattato per il tuo sistema (es. C:\\PROGRA~1\\nodejs\\npx.cmd su Windows).', 'settings.mcp.clientConfigHint': 'Sostituisci <your_token> con un token API dalla lista sottostante. Il percorso di npx potrebbe dover essere adattato per il tuo sistema (es. C:\\PROGRA~1\\nodejs\\npx.cmd su Windows).',
'settings.mcp.clientConfigHintOAuth': 'Replace <your_client_id> and <your_client_secret> with the credentials shown in the OAuth 2.1 client you created above. mcp-remote will open your browser to complete the authorization the first time you connect. The path to npx may need to be adjusted for your system (e.g. C:\PROGRA~1\nodejs\npx.cmd on Windows).',
'settings.mcp.copy': 'Copia', 'settings.mcp.copy': 'Copia',
'settings.mcp.copied': 'Copiato!', 'settings.mcp.copied': 'Copiato!',
'settings.mcp.apiTokens': 'Token API', 'settings.mcp.apiTokens': 'Token API',
+1
View File
@@ -228,6 +228,7 @@ const nl: Record<string, string> = {
'settings.mcp.endpoint': 'MCP-eindpunt', 'settings.mcp.endpoint': 'MCP-eindpunt',
'settings.mcp.clientConfig': 'Clientconfiguratie', 'settings.mcp.clientConfig': 'Clientconfiguratie',
'settings.mcp.clientConfigHint': 'Vervang <your_token> door een API-token uit de onderstaande lijst. Het pad naar npx moet mogelijk worden aangepast voor jouw systeem (bijv. C:\\PROGRA~1\\nodejs\\npx.cmd op Windows).', 'settings.mcp.clientConfigHint': 'Vervang <your_token> door een API-token uit de onderstaande lijst. Het pad naar npx moet mogelijk worden aangepast voor jouw systeem (bijv. C:\\PROGRA~1\\nodejs\\npx.cmd op Windows).',
'settings.mcp.clientConfigHintOAuth': 'Replace <your_client_id> and <your_client_secret> with the credentials shown in the OAuth 2.1 client you created above. mcp-remote will open your browser to complete the authorization the first time you connect. The path to npx may need to be adjusted for your system (e.g. C:\PROGRA~1\nodejs\npx.cmd on Windows).',
'settings.mcp.copy': 'Kopiëren', 'settings.mcp.copy': 'Kopiëren',
'settings.mcp.copied': 'Gekopieerd!', 'settings.mcp.copied': 'Gekopieerd!',
'settings.mcp.apiTokens': 'API-tokens', 'settings.mcp.apiTokens': 'API-tokens',
+1
View File
@@ -198,6 +198,7 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
'settings.mcp.endpoint': 'Endpoint MCP', 'settings.mcp.endpoint': 'Endpoint MCP',
'settings.mcp.clientConfig': 'Konfiguracja klienta', 'settings.mcp.clientConfig': 'Konfiguracja klienta',
'settings.mcp.clientConfigHint': 'Zastąp <your_token> tokenem API z listy poniżej. Ścieżka do npx może wymagać dostosowania do Twojego systemu (np. C:\\PROGRA~1\\nodejs\\npx.cmd w systemie Windows).', 'settings.mcp.clientConfigHint': 'Zastąp <your_token> tokenem API z listy poniżej. Ścieżka do npx może wymagać dostosowania do Twojego systemu (np. C:\\PROGRA~1\\nodejs\\npx.cmd w systemie Windows).',
'settings.mcp.clientConfigHintOAuth': 'Replace <your_client_id> and <your_client_secret> with the credentials shown in the OAuth 2.1 client you created above. mcp-remote will open your browser to complete the authorization the first time you connect. The path to npx may need to be adjusted for your system (e.g. C:\PROGRA~1\nodejs\npx.cmd on Windows).',
'settings.mcp.copy': 'Kopiuj', 'settings.mcp.copy': 'Kopiuj',
'settings.mcp.copied': 'Skopiowano!', 'settings.mcp.copied': 'Skopiowano!',
'settings.mcp.apiTokens': 'Tokeny API', 'settings.mcp.apiTokens': 'Tokeny API',
+1
View File
@@ -228,6 +228,7 @@ const ru: Record<string, string> = {
'settings.mcp.endpoint': 'MCP-эндпоинт', 'settings.mcp.endpoint': 'MCP-эндпоинт',
'settings.mcp.clientConfig': 'Конфигурация клиента', 'settings.mcp.clientConfig': 'Конфигурация клиента',
'settings.mcp.clientConfigHint': 'Замените <your_token> на API-токен из списка ниже. Путь к npx может потребовать настройки для вашей системы (например, C:\\PROGRA~1\\nodejs\\npx.cmd в Windows).', 'settings.mcp.clientConfigHint': 'Замените <your_token> на API-токен из списка ниже. Путь к npx может потребовать настройки для вашей системы (например, C:\\PROGRA~1\\nodejs\\npx.cmd в Windows).',
'settings.mcp.clientConfigHintOAuth': 'Replace <your_client_id> and <your_client_secret> with the credentials shown in the OAuth 2.1 client you created above. mcp-remote will open your browser to complete the authorization the first time you connect. The path to npx may need to be adjusted for your system (e.g. C:\PROGRA~1\nodejs\npx.cmd on Windows).',
'settings.mcp.copy': 'Копировать', 'settings.mcp.copy': 'Копировать',
'settings.mcp.copied': 'Скопировано!', 'settings.mcp.copied': 'Скопировано!',
'settings.mcp.apiTokens': 'API-токены', 'settings.mcp.apiTokens': 'API-токены',
+1
View File
@@ -228,6 +228,7 @@ const zh: Record<string, string> = {
'settings.mcp.endpoint': 'MCP 端点', 'settings.mcp.endpoint': 'MCP 端点',
'settings.mcp.clientConfig': '客户端配置', 'settings.mcp.clientConfig': '客户端配置',
'settings.mcp.clientConfigHint': '将 <your_token> 替换为下方列表中的 API 令牌。npx 的路径可能需要根据您的系统进行调整(例如 Windows 上为 C:\\PROGRA~1\\nodejs\\npx.cmd)。', 'settings.mcp.clientConfigHint': '将 <your_token> 替换为下方列表中的 API 令牌。npx 的路径可能需要根据您的系统进行调整(例如 Windows 上为 C:\\PROGRA~1\\nodejs\\npx.cmd)。',
'settings.mcp.clientConfigHintOAuth': 'Replace <your_client_id> and <your_client_secret> with the credentials shown in the OAuth 2.1 client you created above. mcp-remote will open your browser to complete the authorization the first time you connect. The path to npx may need to be adjusted for your system (e.g. C:\PROGRA~1\nodejs\npx.cmd on Windows).',
'settings.mcp.copy': '复制', 'settings.mcp.copy': '复制',
'settings.mcp.copied': '已复制!', 'settings.mcp.copied': '已复制!',
'settings.mcp.apiTokens': 'API 令牌', 'settings.mcp.apiTokens': 'API 令牌',
+1
View File
@@ -220,6 +220,7 @@ const zhTw: Record<string, string> = {
'settings.mcp.endpoint': 'MCP 端點', 'settings.mcp.endpoint': 'MCP 端點',
'settings.mcp.clientConfig': '客戶端配置', 'settings.mcp.clientConfig': '客戶端配置',
'settings.mcp.clientConfigHint': '將 <your_token> 替換為下方列表中的 API 令牌。npx 的路徑可能需要根據您的系統進行調整(例如 Windows 上為 C:\\PROGRA~1\\nodejs\\npx.cmd)。', 'settings.mcp.clientConfigHint': '將 <your_token> 替換為下方列表中的 API 令牌。npx 的路徑可能需要根據您的系統進行調整(例如 Windows 上為 C:\\PROGRA~1\\nodejs\\npx.cmd)。',
'settings.mcp.clientConfigHintOAuth': 'Replace <your_client_id> and <your_client_secret> with the credentials shown in the OAuth 2.1 client you created above. mcp-remote will open your browser to complete the authorization the first time you connect. The path to npx may need to be adjusted for your system (e.g. C:\PROGRA~1\nodejs\npx.cmd on Windows).',
'settings.mcp.copy': '複製', 'settings.mcp.copy': '複製',
'settings.mcp.copied': '已複製!', 'settings.mcp.copied': '已複製!',
'settings.mcp.apiTokens': 'API 令牌', 'settings.mcp.apiTokens': 'API 令牌',
+214
View File
@@ -0,0 +1,214 @@
import React, { useEffect, useState } from 'react'
import { useAuthStore } from '../store/authStore'
import { oauthApi } from '../api/client'
import { ALL_SCOPES } from '../api/oauthScopes'
import ScopeGroupPicker from '../components/OAuth/ScopeGroupPicker'
import { Lock, ShieldCheck, AlertTriangle, Loader2, LogIn } from 'lucide-react'
interface ValidateResult {
valid: boolean
error?: string
error_description?: string
client_name?: string
requested_scopes?: string[]
loginRequired?: boolean
}
type PageState = 'loading' | 'login_required' | 'ready' | 'error' | 'done'
export default function OAuthRegisterPage(): React.ReactElement {
const { isLoading: authLoading, loadUser } = useAuthStore()
const [pageState, setPageState] = useState<PageState>('loading')
const [validation, setValidation] = useState<ValidateResult | null>(null)
const [selectedScopes, setSelectedScopes] = useState<string[]>([])
const [submitting, setSubmitting] = useState(false)
const [errorMsg, setErrorMsg] = useState<string | null>(null)
const params = new URLSearchParams(window.location.search)
const redirectUri = params.get('redirect_uri') || ''
const clientName = params.get('client_name') || ''
const scope = params.get('scope') || ''
const state = params.get('state') || ''
useEffect(() => {
loadUser({ silent: true }).catch(() => {})
}, [loadUser])
useEffect(() => {
if (authLoading) return
validateRequest()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [authLoading])
async function validateRequest() {
setPageState('loading')
try {
const result: ValidateResult = await oauthApi.register.validate({
redirect_uri: redirectUri,
client_name: clientName,
scope,
state,
})
setValidation(result)
if (!result.valid) {
setPageState('error')
setErrorMsg(result.error_description || result.error || 'Invalid registration request')
return
}
if (result.loginRequired) {
setPageState('login_required')
return
}
// Pre-check the scopes the client requested; fall back to read-only defaults
const requested = result.requested_scopes ?? []
setSelectedScopes(
requested.length > 0
? requested
: ALL_SCOPES.filter(s => s.endsWith(':read')),
)
setPageState('ready')
} catch {
setPageState('error')
setErrorMsg('Failed to validate registration request. Please try again.')
}
}
function handleLoginRedirect() {
const next = '/oauth/register?' + params.toString()
window.location.href = '/login?redirect=' + encodeURIComponent(next)
}
async function submitRegistration(approved: boolean) {
setSubmitting(true)
try {
const result = await oauthApi.register.submit({
client_name: validation?.client_name || clientName || 'MCP Client',
redirect_uri: redirectUri,
scopes: approved ? selectedScopes : [],
state,
approved,
})
setPageState('done')
window.location.href = result.redirect
} catch {
setPageState('error')
setErrorMsg('Registration failed. Please try again.')
setSubmitting(false)
}
}
// ---- Render states ----
if (pageState === 'loading' || pageState === 'done') {
return (
<div className="min-h-screen flex items-center justify-center" style={{ background: 'var(--bg-primary)' }}>
<div className="flex flex-col items-center gap-3">
<Loader2 className="w-8 h-8 animate-spin" style={{ color: 'var(--accent-primary, #4f46e5)' }} />
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>Loading</p>
</div>
</div>
)
}
if (pageState === 'error') {
return (
<div className="min-h-screen flex items-center justify-center p-4" style={{ background: 'var(--bg-primary)' }}>
<div className="w-full max-w-sm rounded-xl shadow-lg p-8 space-y-4 text-center" style={{ background: 'var(--bg-card)' }}>
<AlertTriangle className="w-10 h-10 mx-auto text-red-500" />
<h1 className="text-xl font-semibold" style={{ color: 'var(--text-primary)' }}>Registration Error</h1>
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>{errorMsg}</p>
</div>
</div>
)
}
if (pageState === 'login_required') {
return (
<div className="min-h-screen flex items-center justify-center p-4" style={{ background: 'var(--bg-primary)' }}>
<div className="w-full max-w-sm rounded-xl shadow-lg p-8 space-y-5" style={{ background: 'var(--bg-card)' }}>
<div className="text-center space-y-2">
<Lock className="w-10 h-10 mx-auto" style={{ color: 'var(--accent-primary, #4f46e5)' }} />
<h1 className="text-xl font-semibold" style={{ color: 'var(--text-primary)' }}>Sign in to continue</h1>
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
<strong>{clientName || 'This application'}</strong> wants to register for access to your TREK account. Please sign in first.
</p>
</div>
<button
onClick={handleLoginRedirect}
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg text-sm font-medium text-white"
style={{ background: 'var(--accent-primary, #4f46e5)' }}>
<LogIn className="w-4 h-4" />
Sign in to TREK
</button>
</div>
</div>
)
}
// pageState === 'ready'
const displayName = validation?.client_name || clientName || 'MCP Client'
return (
<div className="min-h-screen flex items-center justify-center p-4" style={{ background: 'var(--bg-primary)' }}>
<div className="w-full max-w-2xl rounded-xl shadow-lg overflow-hidden flex flex-col sm:flex-row" style={{ background: 'var(--bg-card)' }}>
{/* Left panel — identity + actions */}
<div className="sm:w-64 sm:flex-shrink-0 flex flex-col px-8 py-8 sm:border-r" style={{ borderColor: 'var(--border-primary)' }}>
<div className="flex-1 space-y-4">
<div className="w-12 h-12 rounded-full flex items-center justify-center" style={{ background: 'var(--bg-secondary)' }}>
<ShieldCheck className="w-6 h-6" style={{ color: 'var(--accent-primary, #4f46e5)' }} />
</div>
<div>
<p className="text-xs font-medium uppercase tracking-wide mb-1" style={{ color: 'var(--text-tertiary)' }}>Client Registration</p>
<h1 className="text-lg font-semibold leading-snug" style={{ color: 'var(--text-primary)' }}>
{displayName}
</h1>
<p className="text-sm mt-2" style={{ color: 'var(--text-secondary)' }}>
This application wants to access your TREK account. Choose which permissions to grant.
</p>
</div>
<div className="text-xs rounded-lg p-2.5 border" style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-secondary)' }}>
<p className="font-medium mb-0.5" style={{ color: 'var(--text-secondary)' }}>Will redirect to</p>
<p className="font-mono break-all" style={{ color: 'var(--text-tertiary)' }}>{redirectUri}</p>
</div>
</div>
<div className="mt-8 space-y-2">
<p className="text-xs mb-3" style={{ color: 'var(--text-tertiary)' }}>
Only grant access to applications you trust. You can revoke this at any time in Settings.
</p>
<button
onClick={() => submitRegistration(true)}
disabled={submitting || selectedScopes.length === 0}
className="w-full px-4 py-2.5 rounded-lg text-sm font-medium text-white disabled:opacity-60 transition-opacity"
style={{ background: 'var(--accent-primary, #4f46e5)' }}>
{submitting ? 'Registering…' : 'Register & Authorize'}
</button>
<button
onClick={() => submitRegistration(false)}
disabled={submitting}
className="w-full px-4 py-2.5 rounded-lg text-sm font-medium border transition-colors hover:bg-slate-50 dark:hover:bg-slate-800 disabled:opacity-60"
style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}>
Cancel
</button>
</div>
</div>
{/* Right panel — scope picker */}
<div className="flex-1 px-6 py-8 overflow-y-auto max-h-[80vh] sm:max-h-[600px]">
<div className="space-y-4">
<p className="text-xs font-medium uppercase tracking-wide" style={{ color: 'var(--text-tertiary)' }}>
Select permissions
</p>
<ScopeGroupPicker selected={selectedScopes} onChange={setSelectedScopes} />
</div>
</div>
</div>
</div>
)
}
+11 -4
View File
@@ -21,6 +21,7 @@ const wsMock = await import('../../../src/api/websocket');
// Import the API client AFTER the mock is set up so it picks up our getSocketId mock // Import the API client AFTER the mock is set up so it picks up our getSocketId mock
const { const {
apiClient,
authApi, authApi,
tripsApi, tripsApi,
placesApi, placesApi,
@@ -465,19 +466,25 @@ describe('API client interceptors', () => {
}); });
it('FE-API-022: authApi.uploadAvatar sends multipart/form-data', async () => { it('FE-API-022: authApi.uploadAvatar sends multipart/form-data', async () => {
let contentType = ''; // jsdom's FormData ≠ undici's FormData, so Node.js's Request always
// serialises it as text/plain — Content-Type header checks are unreliable.
// MSW wraps XHR in a Proxy, so XHR prototype spies never fire. axios.create()
// copies prototype methods onto the instance as bound functions, so prototype
// spies don't fire either. Spy on the exported apiClient instance directly.
server.use( server.use(
http.post('/api/auth/avatar', ({ request }) => { http.post('/api/auth/avatar', () => {
contentType = request.headers.get('Content-Type') ?? '';
return HttpResponse.json({ avatar_url: '/uploads/avatar.jpg' }); return HttpResponse.json({ avatar_url: '/uploads/avatar.jpg' });
}) })
); );
const postSpy = vi.spyOn(apiClient, 'post');
const formData = new FormData(); const formData = new FormData();
formData.append('avatar', new Blob(['img'], { type: 'image/jpeg' }), 'avatar.jpg'); formData.append('avatar', new Blob(['img'], { type: 'image/jpeg' }), 'avatar.jpg');
await authApi.uploadAvatar(formData); await authApi.uploadAvatar(formData);
expect(contentType).toMatch(/multipart\/form-data/); expect(postSpy).toHaveBeenCalledWith('/auth/avatar', expect.any(FormData), expect.anything());
postSpy.mockRestore();
}); });
it('FE-API-023: authApi.mcpTokens.create posts name to /api/auth/mcp-tokens', async () => { it('FE-API-023: authApi.mcpTokens.create posts name to /api/auth/mcp-tokens', async () => {
+7
View File
@@ -933,6 +933,13 @@ function runMigrations(db: Database.Database): void {
CREATE INDEX IF NOT EXISTS idx_oauth_tokens_parent ON oauth_tokens(parent_token_id); CREATE INDEX IF NOT EXISTS idx_oauth_tokens_parent ON oauth_tokens(parent_token_id);
`); `);
}, },
// Migration: Public client support for browser-initiated dynamic registration (DCR)
() => {
db.exec(`
ALTER TABLE oauth_clients ADD COLUMN is_public INTEGER NOT NULL DEFAULT 0;
ALTER TABLE oauth_clients ADD COLUMN created_via TEXT NOT NULL DEFAULT 'settings_ui';
`);
},
]; ];
if (currentVersion < migrations.length) { if (currentVersion < migrations.length) {
+2 -2
View File
@@ -181,7 +181,7 @@ export function registerTripTools(server: McpServer, userId: number, scopes: str
reservations: canReadRes ? summary.reservations : undefined, reservations: canReadRes ? summary.reservations : undefined,
packing: canReadPacking ? summary.packing : undefined, packing: canReadPacking ? summary.packing : undefined,
budget: canReadBudget ? summary.budget : undefined, budget: canReadBudget ? summary.budget : undefined,
collab_notes: canReadCollab ? summary.collab_notes : undefined, collab_notes: canReadCollab ? summary.collab_notes : [],
todos, todos,
pollCount, pollCount,
messageCount, messageCount,
@@ -198,7 +198,7 @@ export function registerTripTools(server: McpServer, userId: number, scopes: str
reservations: canReadRes ? summary.reservations : undefined, reservations: canReadRes ? summary.reservations : undefined,
packing: canReadPacking ? summary.packing : undefined, packing: canReadPacking ? summary.packing : undefined,
budget: canReadBudget ? summary.budget : undefined, budget: canReadBudget ? summary.budget : undefined,
collab_notes: canReadCollab ? summary.collab_notes : undefined, collab_notes: canReadCollab ? summary.collab_notes : [],
todos, todos,
pollCount, pollCount,
messageCount, messageCount,
+77 -5
View File
@@ -14,6 +14,7 @@ import {
revokeToken, revokeToken,
verifyPKCE, verifyPKCE,
authenticateClient, authenticateClient,
isValidRedirectUri,
listOAuthClients, listOAuthClients,
createOAuthClient, createOAuthClient,
deleteOAuthClient, deleteOAuthClient,
@@ -76,10 +77,11 @@ oauthPublicRouter.get('/.well-known/oauth-authorization-server', (req: Request,
authorization_endpoint: `${base}/oauth/authorize`, authorization_endpoint: `${base}/oauth/authorize`,
token_endpoint: `${base}/oauth/token`, token_endpoint: `${base}/oauth/token`,
revocation_endpoint: `${base}/oauth/revoke`, revocation_endpoint: `${base}/oauth/revoke`,
registration_endpoint: `${base}/oauth/register`,
response_types_supported: ['code'], response_types_supported: ['code'],
grant_types_supported: ['authorization_code', 'refresh_token'], grant_types_supported: ['authorization_code', 'refresh_token'],
code_challenge_methods_supported: ['S256'], code_challenge_methods_supported: ['S256'],
token_endpoint_auth_methods_supported: ['client_secret_post'], token_endpoint_auth_methods_supported: ['client_secret_post', 'none'],
scopes_supported: ALL_SCOPES, scopes_supported: ALL_SCOPES,
scope_descriptions: Object.fromEntries( scope_descriptions: Object.fromEntries(
ALL_SCOPES.map(s => [s, SCOPE_INFO[s].label]) ALL_SCOPES.map(s => [s, SCOPE_INFO[s].label])
@@ -102,8 +104,8 @@ oauthPublicRouter.post('/oauth/token', tokenLimiter, (req: Request, res: Respons
return res.status(403).json({ error: 'mcp_disabled', error_description: 'MCP is not enabled' }); return res.status(403).json({ error: 'mcp_disabled', error_description: 'MCP is not enabled' });
} }
if (!client_id || !client_secret) { if (!client_id) {
return res.status(401).json({ error: 'invalid_client', error_description: 'client_id and client_secret are required' }); return res.status(401).json({ error: 'invalid_client', error_description: 'client_id is required' });
} }
// ---- authorization_code grant ---- // ---- authorization_code grant ----
@@ -180,8 +182,8 @@ oauthPublicRouter.post('/oauth/revoke', revokeLimiter, (req: Request, res: Respo
const { token, client_id, client_secret } = body; const { token, client_id, client_secret } = body;
const ip = getClientIp(req); const ip = getClientIp(req);
if (!token || !client_id || !client_secret) { if (!token || !client_id) {
return res.status(400).json({ error: 'invalid_request', error_description: 'token, client_id, and client_secret are required' }); return res.status(400).json({ error: 'invalid_request', error_description: 'token and client_id are required' });
} }
if (!authenticateClient(client_id, client_secret)) { if (!authenticateClient(client_id, client_secret)) {
@@ -304,6 +306,76 @@ oauthApiRouter.post('/authorize', requireCookieAuth, (req: Request, res: Respons
return res.json({ redirect: url.toString() }); return res.json({ redirect: url.toString() });
}); });
// ---- Browser-initiated dynamic client registration ----
// SPA calls this on load to validate DCR params before rendering scope selection UI
oauthApiRouter.get('/register/validate', validateLimiter, optionalAuth, (req: Request, res: Response) => {
if (!isAddonEnabled(ADDON_IDS.MCP)) return res.status(404).end();
const { redirect_uri, client_name, scope } = req.query as Record<string, string>;
const userId = (req as OptionalAuthRequest).user?.id ?? null;
if (!redirect_uri) {
return res.json({ valid: false, error: 'invalid_request', error_description: 'redirect_uri is required' });
}
if (!isValidRedirectUri(redirect_uri)) {
return res.json({ valid: false, error: 'invalid_redirect_uri', error_description: 'redirect_uri must use HTTPS (localhost is exempt)' });
}
// Anti-fingerprinting: don't expose details to unauthenticated callers
if (userId === null) {
return res.json({ valid: true, loginRequired: true });
}
const resolvedName = (client_name || '').trim().slice(0, 100) || 'MCP Client';
const requestedScopes = (scope || '').split(' ').filter(s => (ALL_SCOPES as string[]).includes(s));
return res.json({ valid: true, client_name: resolvedName, requested_scopes: requestedScopes });
});
// User submits DCR approval (or cancel) — requires cookie auth
oauthApiRouter.post('/register', requireCookieAuth, (req: Request, res: Response) => {
if (!isAddonEnabled(ADDON_IDS.MCP)) return res.status(403).json({ error: 'MCP is not enabled' });
const { user } = req as AuthRequest;
const { client_name, redirect_uri, scopes, state, approved } = req.body as {
client_name: string;
redirect_uri: string;
scopes: string[];
state?: string;
approved?: boolean;
};
const ip = getClientIp(req);
// Validate redirect_uri before constructing any redirect URL
if (!redirect_uri || !isValidRedirectUri(redirect_uri)) {
return res.status(400).json({ error: 'invalid_request', error_description: 'Invalid redirect_uri' });
}
if (approved === false) {
const url = new URL(redirect_uri);
url.searchParams.set('error', 'access_denied');
url.searchParams.set('error_description', 'User cancelled the registration');
if (state) url.searchParams.set('state', state);
return res.json({ redirect: url.toString() });
}
const result = createOAuthClient(
user.id, client_name, [redirect_uri], scopes, ip,
{ isPublic: true, createdVia: 'browser-registration' },
);
if (result.error) return res.status(result.status || 400).json({ error: result.error });
const newClientId = result.client!.client_id as string;
saveConsent(newClientId, user.id, scopes, ip);
const url = new URL(redirect_uri);
url.searchParams.set('client_id', newClientId);
if (state) url.searchParams.set('state', state);
return res.json({ redirect: url.toString() });
});
// ---- OAuth client CRUD ---- // ---- OAuth client CRUD ----
oauthApiRouter.get('/clients', authenticate, (req: Request, res: Response) => { oauthApiRouter.get('/clients', authenticate, (req: Request, res: Response) => {
+48 -17
View File
@@ -55,6 +55,8 @@ interface OAuthClientRow {
redirect_uris: string; // JSON array redirect_uris: string; // JSON array
allowed_scopes: string; // JSON array allowed_scopes: string; // JSON array
created_at: string; created_at: string;
is_public: number; // 0 | 1 (SQLite boolean)
created_via: string; // 'settings_ui' | 'browser-registration'
} }
interface OAuthTokenRow { interface OAuthTokenRow {
@@ -100,21 +102,33 @@ function generateRefreshToken(): string {
export function listOAuthClients(userId: number): Record<string, unknown>[] { export function listOAuthClients(userId: number): Record<string, unknown>[] {
const rows = db.prepare( const rows = db.prepare(
'SELECT id, user_id, name, client_id, redirect_uris, allowed_scopes, created_at FROM oauth_clients WHERE user_id = ? ORDER BY created_at DESC' 'SELECT id, user_id, name, client_id, redirect_uris, allowed_scopes, created_at, is_public, created_via FROM oauth_clients WHERE user_id = ? ORDER BY created_at DESC'
).all(userId) as OAuthClientRow[]; ).all(userId) as OAuthClientRow[];
return rows.map(r => ({ return rows.map(r => ({
...r, ...r,
is_public: Boolean(r.is_public),
redirect_uris: JSON.parse(r.redirect_uris), redirect_uris: JSON.parse(r.redirect_uris),
allowed_scopes: JSON.parse(r.allowed_scopes), allowed_scopes: JSON.parse(r.allowed_scopes),
})); }));
} }
/** Returns true if the URI is a valid OAuth redirect target (HTTPS or localhost). */
export function isValidRedirectUri(uri: string): boolean {
try {
const url = new URL(uri);
return url.protocol === 'https:' || url.hostname === 'localhost' || url.hostname === '127.0.0.1';
} catch {
return false;
}
}
export function createOAuthClient( export function createOAuthClient(
userId: number, userId: number,
name: string, name: string,
redirectUris: string[], redirectUris: string[],
allowedScopes: string[], allowedScopes: string[],
ip?: string | null, ip?: string | null,
options?: { isPublic?: boolean; createdVia?: string },
): { error?: string; status?: number; client?: Record<string, unknown> } { ): { error?: string; status?: number; client?: Record<string, unknown> } {
if (!name?.trim()) return { error: 'Name is required', status: 400 }; if (!name?.trim()) return { error: 'Name is required', status: 400 };
if (name.trim().length > 100) return { error: 'Name must be 100 characters or less', status: 400 }; if (name.trim().length > 100) return { error: 'Name must be 100 characters or less', status: 400 };
@@ -122,14 +136,15 @@ export function createOAuthClient(
if (redirectUris.length > 10) return { error: 'Maximum 10 redirect URIs per client', status: 400 }; if (redirectUris.length > 10) return { error: 'Maximum 10 redirect URIs per client', status: 400 };
for (const uri of redirectUris) { for (const uri of redirectUris) {
let parsed: URL;
try { try {
const url = new URL(uri); parsed = new URL(uri);
if (url.protocol !== 'https:' && url.hostname !== 'localhost' && url.hostname !== '127.0.0.1') {
return { error: `Redirect URI must use HTTPS (localhost exempt): ${uri}`, status: 400 };
}
} catch { } catch {
return { error: `Invalid redirect URI: ${uri}`, status: 400 }; return { error: `Invalid redirect URI: ${uri}`, status: 400 };
} }
if (parsed.protocol !== 'https:' && parsed.hostname !== 'localhost' && parsed.hostname !== '127.0.0.1') {
return { error: `Redirect URI must use HTTPS (localhost exempt): ${uri}`, status: 400 };
}
} }
if (!allowedScopes || allowedScopes.length === 0) return { error: 'At least one scope is required', status: 400 }; if (!allowedScopes || allowedScopes.length === 0) return { error: 'At least one scope is required', status: 400 };
@@ -139,20 +154,23 @@ export function createOAuthClient(
const count = (db.prepare('SELECT COUNT(*) as count FROM oauth_clients WHERE user_id = ?').get(userId) as { count: number }).count; const count = (db.prepare('SELECT COUNT(*) as count FROM oauth_clients WHERE user_id = ?').get(userId) as { count: number }).count;
if (count >= 10) return { error: 'Maximum of 10 OAuth clients per user', status: 400 }; if (count >= 10) return { error: 'Maximum of 10 OAuth clients per user', status: 400 };
const isPublic = options?.isPublic ?? false;
const createdVia = options?.createdVia ?? 'settings_ui';
const id = randomUUID(); const id = randomUUID();
const clientId = randomUUID(); const clientId = randomUUID();
const rawSecret = 'trekcs_' + randomBytes(24).toString('hex'); // Public clients have no usable secret; store an opaque random value to satisfy NOT NULL.
const secretHash = hashToken(rawSecret); const rawSecret = isPublic ? null : 'trekcs_' + randomBytes(24).toString('hex');
const secretHash = rawSecret ? hashToken(rawSecret) : randomBytes(32).toString('hex');
db.prepare( db.prepare(
'INSERT INTO oauth_clients (id, user_id, name, client_id, client_secret_hash, redirect_uris, allowed_scopes) VALUES (?, ?, ?, ?, ?, ?, ?)' 'INSERT INTO oauth_clients (id, user_id, name, client_id, client_secret_hash, redirect_uris, allowed_scopes, is_public, created_via) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)'
).run(id, userId, name.trim(), clientId, secretHash, JSON.stringify(redirectUris), JSON.stringify(allowedScopes)); ).run(id, userId, name.trim(), clientId, secretHash, JSON.stringify(redirectUris), JSON.stringify(allowedScopes), isPublic ? 1 : 0, createdVia);
const row = db.prepare( const row = db.prepare(
'SELECT id, user_id, name, client_id, redirect_uris, allowed_scopes, created_at FROM oauth_clients WHERE id = ?' 'SELECT id, user_id, name, client_id, redirect_uris, allowed_scopes, created_at, is_public, created_via FROM oauth_clients WHERE id = ?'
).get(id) as OAuthClientRow; ).get(id) as OAuthClientRow;
writeAudit({ userId, action: 'oauth.client.create', details: { client_id: clientId, name: name.trim() }, ip }); writeAudit({ userId, action: 'oauth.client.create', details: { client_id: clientId, name: name.trim(), is_public: isPublic }, ip });
return { return {
client: { client: {
@@ -163,7 +181,10 @@ export function createOAuthClient(
redirect_uris: JSON.parse(row.redirect_uris), redirect_uris: JSON.parse(row.redirect_uris),
allowed_scopes: JSON.parse(row.allowed_scopes), allowed_scopes: JSON.parse(row.allowed_scopes),
created_at: row.created_at, created_at: row.created_at,
client_secret: rawSecret, // shown once — not stored in plain text is_public: Boolean(row.is_public),
created_via: row.created_via,
// client_secret only present for confidential clients — shown once, not stored in plain text
...(rawSecret ? { client_secret: rawSecret } : {}),
}, },
}; };
} }
@@ -173,8 +194,9 @@ export function rotateOAuthClientSecret(
clientRowId: string, clientRowId: string,
ip?: string | null, ip?: string | null,
): { error?: string; status?: number; client_secret?: string } { ): { error?: string; status?: number; client_secret?: string } {
const row = db.prepare('SELECT id, client_id FROM oauth_clients WHERE id = ? AND user_id = ?').get(clientRowId, userId) as OAuthClientRow | undefined; const row = db.prepare('SELECT id, client_id, is_public FROM oauth_clients WHERE id = ? AND user_id = ?').get(clientRowId, userId) as OAuthClientRow | undefined;
if (!row) return { error: 'Client not found', status: 404 }; if (!row) return { error: 'Client not found', status: 404 };
if (row.is_public) return { error: 'Public clients do not use a client secret', status: 400 };
const rawSecret = 'trekcs_' + randomBytes(24).toString('hex'); const rawSecret = 'trekcs_' + randomBytes(24).toString('hex');
const secretHash = hashToken(rawSecret); const secretHash = hashToken(rawSecret);
@@ -363,12 +385,16 @@ function revokeChain(rootId: number): number[] {
export function refreshTokens( export function refreshTokens(
rawRefreshToken: string, rawRefreshToken: string,
clientId: string, clientId: string,
clientSecret: string, clientSecret: string | undefined,
ip?: string | null, ip?: string | null,
): { error?: string; status?: number; tokens?: ReturnType<typeof issueTokens> } { ): { error?: string; status?: number; tokens?: ReturnType<typeof issueTokens> } {
const client = db.prepare('SELECT client_id, client_secret_hash FROM oauth_clients WHERE client_id = ?').get(clientId) as OAuthClientRow | undefined; const client = db.prepare('SELECT client_id, client_secret_hash, is_public FROM oauth_clients WHERE client_id = ?').get(clientId) as OAuthClientRow | undefined;
if (!client) return { error: 'invalid_client', status: 401 }; if (!client) return { error: 'invalid_client', status: 401 };
if (!timingSafeEqualHex(hashToken(clientSecret), client.client_secret_hash)) return { error: 'invalid_client', status: 401 }; if (!client.is_public) {
if (!clientSecret || !timingSafeEqualHex(hashToken(clientSecret), client.client_secret_hash)) {
return { error: 'invalid_client', status: 401 };
}
}
const hash = hashToken(rawRefreshToken); const hash = hashToken(rawRefreshToken);
const row = db.prepare(` const row = db.prepare(`
@@ -587,10 +613,15 @@ export function verifyPKCE(codeVerifier: string, codeChallenge: string): boolean
// Client authentication (for token endpoint) // Client authentication (for token endpoint)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export function authenticateClient(clientId: string, clientSecret: string): OAuthClientRow | null { export function authenticateClient(clientId: string, clientSecret: string | undefined): OAuthClientRow | null {
const client = db.prepare('SELECT * FROM oauth_clients WHERE client_id = ?').get(clientId) as OAuthClientRow | undefined; const client = db.prepare('SELECT * FROM oauth_clients WHERE client_id = ?').get(clientId) as OAuthClientRow | undefined;
if (!client) return null; if (!client) return null;
if (client.is_public) {
// Public clients are identified by client_id alone — PKCE provides the security guarantee.
return client;
}
// H4: constant-time comparison to prevent timing side-channel // H4: constant-time comparison to prevent timing side-channel
if (!clientSecret) return null;
if (!timingSafeEqualHex(hashToken(clientSecret), client.client_secret_hash)) return null; if (!timingSafeEqualHex(hashToken(clientSecret), client.client_secret_hash)) return null;
return client; return client;
} }