mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 13:21:46 +00:00
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:
@@ -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={
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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-токены',
|
||||||
|
|||||||
@@ -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 令牌',
|
||||||
|
|||||||
@@ -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 令牌',
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user