Add Odoo connection scoping UI to API Tokens page
When creating a key, pick "All connections (unscoped)" or lock it to one specific Odoo connection — matches the new per-key instance_name scoping on the backend. The keys table now shows each key's scope (or "All connections" for unscoped keys). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
98de0a4675
commit
df82c51754
@ -87,6 +87,7 @@ export interface ApiKeyInfo {
|
|||||||
last_used_at: string | null;
|
last_used_at: string | null;
|
||||||
revoked_at: string | null;
|
revoked_at: string | null;
|
||||||
is_active: boolean;
|
is_active: boolean;
|
||||||
|
instance_name: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AnalyticsSummary {
|
export interface AnalyticsSummary {
|
||||||
@ -213,10 +214,10 @@ export const api = {
|
|||||||
listApiKeys: (apiKey: string) =>
|
listApiKeys: (apiKey: string) =>
|
||||||
req<{ keys: ApiKeyInfo[]; total: number }>('/api/keys', { headers: authHeader(apiKey) }),
|
req<{ keys: ApiKeyInfo[]; total: number }>('/api/keys', { headers: authHeader(apiKey) }),
|
||||||
|
|
||||||
createApiKey: (apiKey: string, name: string) =>
|
createApiKey: (apiKey: string, name: string, instanceName?: string) =>
|
||||||
req<{ id: number; name: string; api_key: string; mcp_connection: { url: string; header: string } }>(
|
req<{ id: number; name: string; instance_name: string | null; api_key: string; mcp_connection: { url: string; header: string } }>(
|
||||||
'/api/keys',
|
'/api/keys',
|
||||||
{ method: 'POST', headers: authHeader(apiKey), body: JSON.stringify({ name }) },
|
{ method: 'POST', headers: authHeader(apiKey), body: JSON.stringify({ name, instance_name: instanceName || null }) },
|
||||||
),
|
),
|
||||||
|
|
||||||
revokeApiKey: (apiKey: string, id: number) =>
|
revokeApiKey: (apiKey: string, id: number) =>
|
||||||
|
|||||||
@ -4,8 +4,10 @@ import { Plus, Trash2, Key, Copy, Check, AlertTriangle } from 'lucide-react';
|
|||||||
|
|
||||||
export default function Tokens() {
|
export default function Tokens() {
|
||||||
const [keys, setKeys] = useState<ApiKeyInfo[]>([]);
|
const [keys, setKeys] = useState<ApiKeyInfo[]>([]);
|
||||||
|
const [instances, setInstances] = useState<string[]>([]);
|
||||||
const [showForm, setShowForm] = useState(false);
|
const [showForm, setShowForm] = useState(false);
|
||||||
const [name, setName] = useState('');
|
const [name, setName] = useState('');
|
||||||
|
const [scopedInstance, setScopedInstance] = useState('');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [newKey, setNewKey] = useState<string | null>(null);
|
const [newKey, setNewKey] = useState<string | null>(null);
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
@ -14,6 +16,7 @@ export default function Tokens() {
|
|||||||
const key = session.getKey();
|
const key = session.getKey();
|
||||||
if (!key) return;
|
if (!key) return;
|
||||||
api.listApiKeys(key).then((r) => setKeys(r.keys)).catch(console.error);
|
api.listApiKeys(key).then((r) => setKeys(r.keys)).catch(console.error);
|
||||||
|
api.listCredentials(key).then((r) => setInstances(r.instances.map((i) => i.instance_name))).catch(console.error);
|
||||||
}
|
}
|
||||||
useEffect(load, []);
|
useEffect(load, []);
|
||||||
|
|
||||||
@ -23,9 +26,10 @@ export default function Tokens() {
|
|||||||
if (!key) return;
|
if (!key) return;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const result = await api.createApiKey(key, name || 'Untitled key');
|
const result = await api.createApiKey(key, name || 'Untitled key', scopedInstance || undefined);
|
||||||
setNewKey(result.api_key);
|
setNewKey(result.api_key);
|
||||||
setName('');
|
setName('');
|
||||||
|
setScopedInstance('');
|
||||||
setShowForm(false);
|
setShowForm(false);
|
||||||
load();
|
load();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@ -60,7 +64,8 @@ export default function Tokens() {
|
|||||||
<div>
|
<div>
|
||||||
<h1 className="text-xl font-bold text-gray-900">API Tokens</h1>
|
<h1 className="text-xl font-bold text-gray-900">API Tokens</h1>
|
||||||
<p className="text-sm text-gray-500 mt-0.5">
|
<p className="text-sm text-gray-500 mt-0.5">
|
||||||
Create and revoke API keys used to connect MCP clients.
|
Create and revoke API keys used to connect MCP clients. Each key can be locked
|
||||||
|
to a single Odoo connection, or left unscoped to access all of them.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@ -116,6 +121,24 @@ export default function Tokens() {
|
|||||||
className="w-full border border-gray-200 rounded-lg px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent"
|
className="w-full border border-gray-200 rounded-lg px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-gray-700 mb-1.5">Odoo Connection</label>
|
||||||
|
<select
|
||||||
|
value={scopedInstance}
|
||||||
|
onChange={(e) => setScopedInstance(e.target.value)}
|
||||||
|
className="w-full border border-gray-200 rounded-lg px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-brand-500"
|
||||||
|
>
|
||||||
|
<option value="">All connections (unscoped)</option>
|
||||||
|
{instances.map((i) => (
|
||||||
|
<option key={i} value={i}>{i}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<p className="text-xs text-gray-400 mt-1.5">
|
||||||
|
{scopedInstance
|
||||||
|
? `This key will only work with the "${scopedInstance}" connection.`
|
||||||
|
: 'This key can access any of your Odoo connections.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
<div className="flex gap-3 pt-2">
|
<div className="flex gap-3 pt-2">
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
@ -151,6 +174,7 @@ export default function Tokens() {
|
|||||||
<tr className="text-left text-xs text-gray-500">
|
<tr className="text-left text-xs text-gray-500">
|
||||||
<th className="px-5 py-3 font-medium">Name</th>
|
<th className="px-5 py-3 font-medium">Name</th>
|
||||||
<th className="px-5 py-3 font-medium">Key</th>
|
<th className="px-5 py-3 font-medium">Key</th>
|
||||||
|
<th className="px-5 py-3 font-medium">Odoo Connection</th>
|
||||||
<th className="px-5 py-3 font-medium">Created</th>
|
<th className="px-5 py-3 font-medium">Created</th>
|
||||||
<th className="px-5 py-3 font-medium">Last Used</th>
|
<th className="px-5 py-3 font-medium">Last Used</th>
|
||||||
<th className="px-5 py-3 font-medium">Status</th>
|
<th className="px-5 py-3 font-medium">Status</th>
|
||||||
@ -162,6 +186,15 @@ export default function Tokens() {
|
|||||||
<tr key={k.id}>
|
<tr key={k.id}>
|
||||||
<td className="px-5 py-3.5 font-medium text-gray-900">{k.name}</td>
|
<td className="px-5 py-3.5 font-medium text-gray-900">{k.name}</td>
|
||||||
<td className="px-5 py-3.5 font-mono text-xs text-gray-500">{k.key_prefix}...</td>
|
<td className="px-5 py-3.5 font-mono text-xs text-gray-500">{k.key_prefix}...</td>
|
||||||
|
<td className="px-5 py-3.5">
|
||||||
|
{k.instance_name ? (
|
||||||
|
<span className="inline-flex items-center gap-1 text-xs text-brand-700 bg-brand-50 px-2 py-0.5 rounded-full">
|
||||||
|
{k.instance_name}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-gray-400">All connections</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
<td className="px-5 py-3.5 text-xs text-gray-500">{k.created_at}</td>
|
<td className="px-5 py-3.5 text-xs text-gray-500">{k.created_at}</td>
|
||||||
<td className="px-5 py-3.5 text-xs text-gray-500">{k.last_used_at ?? 'Never'}</td>
|
<td className="px-5 py-3.5 text-xs text-gray-500">{k.last_used_at ?? 'Never'}</td>
|
||||||
<td className="px-5 py-3.5">
|
<td className="px-5 py-3.5">
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user