feat: add MCP endpoint and API key management
- Add ApiKey Prisma model (SHA-256 hash, prefix, lastUsedAt) with migration - Implement ApiKeyService (create, list, delete, verify) - Extend requireAuth middleware to accept sk_-prefixed API keys alongside JWTs - Add GET/POST /api-keys routes for creating and revoking keys - Add stateless Streamable HTTP MCP server at POST/GET /mcp exposing all 20 time-tracking tools (clients, projects, time entries, timer, statistics, client targets and corrections) - Frontend: ApiKey types, apiKeys API module, useApiKeys hook - Frontend: ApiKeysPage with key table, one-time raw-key reveal modal, and inline revoke confirmation - Wire /api-keys route and add API Keys link to Management dropdown in Navbar
This commit is contained in:
@@ -10,6 +10,7 @@ import { TimeEntriesPage } from "./pages/TimeEntriesPage";
|
||||
import { ClientsPage } from "./pages/ClientsPage";
|
||||
import { ProjectsPage } from "./pages/ProjectsPage";
|
||||
import { StatisticsPage } from "./pages/StatisticsPage";
|
||||
import { ApiKeysPage } from "./pages/ApiKeysPage";
|
||||
|
||||
function App() {
|
||||
return (
|
||||
@@ -33,6 +34,7 @@ function App() {
|
||||
<Route path="clients" element={<ClientsPage />} />
|
||||
<Route path="projects" element={<ProjectsPage />} />
|
||||
<Route path="statistics" element={<StatisticsPage />} />
|
||||
<Route path="api-keys" element={<ApiKeysPage />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</AuthProvider>
|
||||
|
||||
18
frontend/src/api/apiKeys.ts
Normal file
18
frontend/src/api/apiKeys.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import apiClient from './client';
|
||||
import type { ApiKey, CreatedApiKey, CreateApiKeyInput } from '@/types';
|
||||
|
||||
export const apiKeysApi = {
|
||||
getAll: async (): Promise<ApiKey[]> => {
|
||||
const { data } = await apiClient.get<ApiKey[]>('/api-keys');
|
||||
return data;
|
||||
},
|
||||
|
||||
create: async (input: CreateApiKeyInput): Promise<CreatedApiKey> => {
|
||||
const { data } = await apiClient.post<CreatedApiKey>('/api-keys', input);
|
||||
return data;
|
||||
},
|
||||
|
||||
delete: async (id: string): Promise<void> => {
|
||||
await apiClient.delete(`/api-keys/${id}`);
|
||||
},
|
||||
};
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
LogOut,
|
||||
ChevronDown,
|
||||
Settings,
|
||||
Key,
|
||||
} from "lucide-react";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
@@ -40,6 +41,7 @@ export function Navbar() {
|
||||
const managementItems = [
|
||||
{ to: "/clients", label: "Clients", icon: Briefcase },
|
||||
{ to: "/projects", label: "Projects", icon: FolderOpen },
|
||||
{ to: "/api-keys", label: "API Keys", icon: Key },
|
||||
];
|
||||
|
||||
return (
|
||||
|
||||
34
frontend/src/hooks/useApiKeys.ts
Normal file
34
frontend/src/hooks/useApiKeys.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { apiKeysApi } from '@/api/apiKeys';
|
||||
import type { CreateApiKeyInput } from '@/types';
|
||||
|
||||
export function useApiKeys() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: apiKeys, isLoading, error } = useQuery({
|
||||
queryKey: ['apiKeys'],
|
||||
queryFn: apiKeysApi.getAll,
|
||||
});
|
||||
|
||||
const createApiKey = useMutation({
|
||||
mutationFn: (input: CreateApiKeyInput) => apiKeysApi.create(input),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['apiKeys'] });
|
||||
},
|
||||
});
|
||||
|
||||
const deleteApiKey = useMutation({
|
||||
mutationFn: (id: string) => apiKeysApi.delete(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['apiKeys'] });
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
apiKeys,
|
||||
isLoading,
|
||||
error,
|
||||
createApiKey,
|
||||
deleteApiKey,
|
||||
};
|
||||
}
|
||||
235
frontend/src/pages/ApiKeysPage.tsx
Normal file
235
frontend/src/pages/ApiKeysPage.tsx
Normal file
@@ -0,0 +1,235 @@
|
||||
import { useState } from "react";
|
||||
import { Key, Plus, Trash2, Copy, Check, AlertTriangle } from "lucide-react";
|
||||
import { useApiKeys } from "@/hooks/useApiKeys";
|
||||
import type { CreatedApiKey } from "@/types";
|
||||
|
||||
export function ApiKeysPage() {
|
||||
const { apiKeys, isLoading, error, createApiKey, deleteApiKey } = useApiKeys();
|
||||
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [newKeyName, setNewKeyName] = useState("");
|
||||
const [createError, setCreateError] = useState<string | null>(null);
|
||||
const [createdKey, setCreatedKey] = useState<CreatedApiKey | null>(null);
|
||||
const [copiedKey, setCopiedKey] = useState(false);
|
||||
const [revokeConfirmId, setRevokeConfirmId] = useState<string | null>(null);
|
||||
|
||||
function formatDate(dateStr: string | null) {
|
||||
if (!dateStr) return "Never";
|
||||
return new Date(dateStr).toLocaleString();
|
||||
}
|
||||
|
||||
async function handleCreate() {
|
||||
if (!newKeyName.trim()) return;
|
||||
setCreateError(null);
|
||||
try {
|
||||
const key = await createApiKey.mutateAsync({ name: newKeyName.trim() });
|
||||
setCreatedKey(key);
|
||||
setNewKeyName("");
|
||||
} catch (err) {
|
||||
setCreateError(err instanceof Error ? err.message : "An error occurred");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCopyKey() {
|
||||
if (!createdKey) return;
|
||||
await navigator.clipboard.writeText(createdKey.rawKey);
|
||||
setCopiedKey(true);
|
||||
setTimeout(() => setCopiedKey(false), 2000);
|
||||
}
|
||||
|
||||
function handleCloseCreateModal() {
|
||||
setShowCreateModal(false);
|
||||
setCreatedKey(null);
|
||||
setNewKeyName("");
|
||||
setCreateError(null);
|
||||
setCopiedKey(false);
|
||||
}
|
||||
|
||||
async function handleRevoke(id: string) {
|
||||
try {
|
||||
await deleteApiKey.mutateAsync(id);
|
||||
setRevokeConfirmId(null);
|
||||
} catch (err) {
|
||||
// error is surfaced inline via mutation state
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto py-8 px-4">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Key className="h-6 w-6 text-gray-600" />
|
||||
<h1 className="text-2xl font-bold text-gray-900">API Keys</h1>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 bg-primary-600 text-white text-sm font-medium rounded-lg hover:bg-primary-700 transition-colors"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Create API Key
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-gray-500 mb-6">
|
||||
API keys allow agents and external tools to authenticate with the TimeTracker API and MCP endpoint.
|
||||
The raw key is only shown once at creation time — store it securely.
|
||||
</p>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-red-700 text-sm">
|
||||
{error instanceof Error ? error.message : "Failed to load API keys"}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<div className="text-center py-12 text-gray-400 text-sm">Loading...</div>
|
||||
) : !apiKeys || apiKeys.length === 0 ? (
|
||||
<div className="text-center py-12 border border-dashed border-gray-300 rounded-lg">
|
||||
<Key className="h-10 w-10 text-gray-300 mx-auto mb-3" />
|
||||
<p className="text-gray-500 text-sm">No API keys yet. Create one to get started.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="border border-gray-200 rounded-lg overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50 border-b border-gray-200">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-600">Name</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-600">Prefix</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-600">Created</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-600">Last Used</th>
|
||||
<th className="px-4 py-3 text-right font-medium text-gray-600">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{apiKeys.map((key) => (
|
||||
<tr key={key.id} className="hover:bg-gray-50">
|
||||
<td className="px-4 py-3 font-medium text-gray-900">{key.name}</td>
|
||||
<td className="px-4 py-3">
|
||||
<code className="text-xs bg-gray-100 px-2 py-1 rounded font-mono text-gray-700">
|
||||
{key.prefix}…
|
||||
</code>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-gray-500">{formatDate(key.createdAt)}</td>
|
||||
<td className="px-4 py-3 text-gray-500">{formatDate(key.lastUsedAt)}</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
{revokeConfirmId === key.id ? (
|
||||
<div className="inline-flex items-center gap-2">
|
||||
<span className="text-xs text-red-600">Revoke?</span>
|
||||
<button
|
||||
onClick={() => handleRevoke(key.id)}
|
||||
disabled={deleteApiKey.isPending}
|
||||
className="text-xs px-2 py-1 bg-red-600 text-white rounded hover:bg-red-700 transition-colors disabled:opacity-50"
|
||||
>
|
||||
Yes
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setRevokeConfirmId(null)}
|
||||
className="text-xs px-2 py-1 border border-gray-300 rounded hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setRevokeConfirmId(key.id)}
|
||||
className="inline-flex items-center gap-1 px-2 py-1 text-red-600 hover:text-red-800 hover:bg-red-50 rounded transition-colors"
|
||||
title="Revoke key"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
<span>Revoke</span>
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create API Key Modal */}
|
||||
{showCreateModal && (
|
||||
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-xl shadow-xl w-full max-w-md">
|
||||
<div className="px-6 py-4 border-b border-gray-200">
|
||||
<h2 className="text-lg font-semibold text-gray-900">Create API Key</h2>
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-5">
|
||||
{createdKey ? (
|
||||
/* One-time key reveal */
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start gap-3 p-3 bg-amber-50 border border-amber-200 rounded-lg">
|
||||
<AlertTriangle className="h-5 w-5 text-amber-500 flex-shrink-0 mt-0.5" />
|
||||
<p className="text-sm text-amber-800">
|
||||
Copy this key now. <strong>It will not be shown again.</strong>
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-500 mb-1">Your new API key</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 text-xs bg-gray-100 border border-gray-200 rounded-lg px-3 py-2 font-mono text-gray-900 break-all">
|
||||
{createdKey.rawKey}
|
||||
</code>
|
||||
<button
|
||||
onClick={handleCopyKey}
|
||||
className="flex-shrink-0 p-2 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
|
||||
title="Copy to clipboard"
|
||||
>
|
||||
{copiedKey ? (
|
||||
<Check className="h-4 w-4 text-green-600" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4 text-gray-500" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* Name input form */
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="key-name" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Key name
|
||||
</label>
|
||||
<input
|
||||
id="key-name"
|
||||
type="text"
|
||||
value={newKeyName}
|
||||
onChange={(e) => setNewKeyName(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleCreate()}
|
||||
placeholder="e.g. My Claude Agent"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
{createError && (
|
||||
<p className="text-red-600 text-sm">{createError}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-4 border-t border-gray-200 flex justify-end gap-3">
|
||||
<button
|
||||
onClick={handleCloseCreateModal}
|
||||
className="px-4 py-2 text-sm font-medium border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
{createdKey ? "Done" : "Cancel"}
|
||||
</button>
|
||||
{!createdKey && (
|
||||
<button
|
||||
onClick={handleCreate}
|
||||
disabled={!newKeyName.trim() || createApiKey.isPending}
|
||||
className="px-4 py-2 text-sm font-medium bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{createApiKey.isPending ? "Creating..." : "Create"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -210,3 +210,19 @@ export interface CreateCorrectionInput {
|
||||
hours: number;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface ApiKey {
|
||||
id: string;
|
||||
name: string;
|
||||
prefix: string;
|
||||
createdAt: string;
|
||||
lastUsedAt: string | null;
|
||||
}
|
||||
|
||||
export interface CreatedApiKey extends ApiKey {
|
||||
rawKey: string; // returned only on creation
|
||||
}
|
||||
|
||||
export interface CreateApiKeyInput {
|
||||
name: string;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user