feat: add env vars tab and URL-based tab linking

- Move environment variables to dedicated tab on agent and collection pages
- Add URL query param support for deep linking to tabs (?tab=env-vars)
- Remove env vars from settings tab on both pages
This commit is contained in:
Ajax Davis 2026-01-14 09:22:37 +10:00
parent 3955cde54b
commit c0511f3628
2 changed files with 870 additions and 764 deletions

File diff suppressed because it is too large Load diff

View file

@ -4,149 +4,52 @@ import type { ExecutorConfig } from '@tpmjs/types/executor';
import { Badge } from '@tpmjs/ui/Badge/Badge';
import { Button } from '@tpmjs/ui/Button/Button';
import { Icon } from '@tpmjs/ui/Icon/Icon';
import {
Table,
TableBody,
TableCell,
TableEmpty,
TableHead,
TableHeader,
TableRow,
} from '@tpmjs/ui/Table/Table';
import { Tabs } from '@tpmjs/ui/Tabs/Tabs';
import Link from 'next/link';
import { useParams, useRouter } from 'next/navigation';
import { useParams, useRouter, useSearchParams } from 'next/navigation';
import { useCallback, useEffect, useState } from 'react';
import { AddToolSearch } from '~/components/collections/AddToolSearch';
import { CollectionForm } from '~/components/collections/CollectionForm';
import { CollectionToolList } from '~/components/collections/CollectionToolList';
import { DashboardLayout } from '~/components/dashboard/DashboardLayout';
import { EnvVarsEditor } from '~/components/EnvVarsEditor';
import { ExecutorConfigPanel } from '~/components/ExecutorConfigPanel';
function McpUrlSection({ username, slug }: { username: string; slug: string }) {
const [copiedUrl, setCopiedUrl] = useState<'http' | 'sse' | null>(null);
const [showConfig, setShowConfig] = useState(false);
// MCP URL display component
function McpUrlDisplay({ url, label, sublabel }: { url: string; label: string; sublabel: string }) {
const [copied, setCopied] = useState(false);
const baseUrl = typeof window !== 'undefined' ? window.location.origin : 'https://tpmjs.com';
const httpUrl = `${baseUrl}/api/mcp/${username}/${slug}/http`;
const sseUrl = `${baseUrl}/api/mcp/${username}/${slug}/sse`;
const copyToClipboard = async (url: string, type: 'http' | 'sse') => {
const copyToClipboard = async () => {
await navigator.clipboard.writeText(url);
setCopiedUrl(type);
setTimeout(() => setCopiedUrl(null), 2000);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
const configSnippet = `{
"mcpServers": {
"tpmjs-collection": {
"command": "npx",
"args": [
"mcp-remote",
"${httpUrl}",
"--header",
"Authorization: Bearer YOUR_TPMJS_API_KEY"
]
}
}
}`;
return (
<div className="mb-8 p-4 bg-gradient-to-br from-primary/5 via-transparent to-primary/5 border border-primary/20 rounded-xl">
<div className="flex items-center gap-2 mb-4">
<div className="p-1.5 bg-primary/10 rounded-lg">
<Icon icon="link" size="sm" className="text-primary" />
</div>
<h3 className="font-semibold text-foreground">MCP Server URLs</h3>
<Badge variant="secondary" size="sm">
Public
</Badge>
<div>
<div className="flex items-center gap-2 mb-1.5">
<span className="text-xs font-medium text-foreground-secondary uppercase tracking-wide">
{label}
</span>
<span className="text-xs text-foreground-tertiary">({sublabel})</span>
</div>
<div className="space-y-3">
{/* HTTP Transport */}
<div className="group">
<div className="flex items-center gap-2 mb-1.5">
<span className="text-xs font-medium text-foreground-secondary uppercase tracking-wide">
HTTP Transport
</span>
<span className="text-xs text-foreground-tertiary">(recommended)</span>
</div>
<div className="flex items-center gap-2">
<div className="flex-1 px-3 py-2 bg-surface border border-border rounded-lg font-mono text-sm text-foreground-secondary overflow-x-auto">
{httpUrl}
</div>
<Button
variant="secondary"
size="sm"
onClick={() => copyToClipboard(httpUrl, 'http')}
className="shrink-0"
>
<Icon icon={copiedUrl === 'http' ? 'check' : 'copy'} size="xs" className="mr-1" />
{copiedUrl === 'http' ? 'Copied!' : 'Copy'}
</Button>
</div>
</div>
{/* SSE Transport */}
<div className="group">
<div className="flex items-center gap-2 mb-1.5">
<span className="text-xs font-medium text-foreground-secondary uppercase tracking-wide">
SSE Transport
</span>
<span className="text-xs text-foreground-tertiary">(streaming)</span>
</div>
<div className="flex items-center gap-2">
<div className="flex-1 px-3 py-2 bg-surface border border-border rounded-lg font-mono text-sm text-foreground-secondary overflow-x-auto">
{sseUrl}
</div>
<Button
variant="secondary"
size="sm"
onClick={() => copyToClipboard(sseUrl, 'sse')}
className="shrink-0"
>
<Icon icon={copiedUrl === 'sse' ? 'check' : 'copy'} size="xs" className="mr-1" />
{copiedUrl === 'sse' ? 'Copied!' : 'Copy'}
</Button>
</div>
<div className="flex items-center gap-2">
<div className="flex-1 px-3 py-2 bg-surface border border-border rounded-lg font-mono text-sm text-foreground-secondary overflow-x-auto">
{url}
</div>
<Button variant="secondary" size="sm" onClick={copyToClipboard} className="shrink-0">
<Icon icon={copied ? 'check' : 'copy'} size="xs" className="mr-1" />
{copied ? 'Copied!' : 'Copy'}
</Button>
</div>
{/* Config snippet toggle */}
<div className="mt-4 pt-4 border-t border-border/50">
<button
type="button"
onClick={() => setShowConfig(!showConfig)}
className="flex items-center gap-2 text-sm text-primary hover:text-primary/80 transition-colors"
>
<Icon icon={showConfig ? 'chevronDown' : 'chevronRight'} size="xs" />
<span>Show Claude Desktop config</span>
</button>
{showConfig && (
<div className="mt-3 relative">
<pre className="p-4 bg-surface border border-border rounded-lg text-xs font-mono text-foreground-secondary overflow-x-auto">
{configSnippet}
</pre>
<Button
variant="ghost"
size="sm"
onClick={() => {
navigator.clipboard.writeText(configSnippet);
setCopiedUrl('http');
setTimeout(() => setCopiedUrl(null), 2000);
}}
className="absolute top-2 right-2"
>
<Icon icon="copy" size="xs" />
</Button>
</div>
)}
</div>
<p className="mt-3 text-xs text-foreground-tertiary">
Use these URLs with{' '}
<Link href="/docs/tutorials/mcp" className="text-primary hover:underline">
Claude Desktop, Cursor, or any MCP client
</Link>
. Requires your{' '}
<Link href="/dashboard/settings" className="text-primary hover:underline">
TPMJS API key
</Link>{' '}
for authentication.
</p>
</div>
);
}
@ -188,25 +91,47 @@ interface Collection {
tools: CollectionTool[];
}
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Page component with multiple handlers
type TabId = 'tools' | 'connect' | 'env-vars' | 'settings';
const VALID_TABS: TabId[] = ['tools', 'connect', 'env-vars', 'settings'];
export default function CollectionDetailPage(): React.ReactElement {
const params = useParams();
const router = useRouter();
const searchParams = useSearchParams();
const collectionId = params.id as string;
// Get initial tab from URL or default to 'tools'
const tabFromUrl = searchParams.get('tab') as TabId | null;
const initialTab = tabFromUrl && VALID_TABS.includes(tabFromUrl) ? tabFromUrl : 'tools';
const [collection, setCollection] = useState<Collection | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [isEditing, setIsEditing] = useState(false);
const [activeTab, setActiveTab] = useState<TabId>(initialTab);
const [isUpdating, setIsUpdating] = useState(false);
const [removingToolId, setRemovingToolId] = useState<string | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
const [executorConfig, setExecutorConfig] = useState<ExecutorConfig | null>(null);
// Environment variables state (stored as object for the EnvVarsEditor component)
const [envVars, setEnvVars] = useState<Record<string, string> | null>(null);
const [showClaudeConfig, setShowClaudeConfig] = useState(false);
// Update URL when tab changes
const handleTabChange = (tabId: string) => {
const newTab = tabId as TabId;
setActiveTab(newTab);
const newParams = new URLSearchParams(searchParams.toString());
if (newTab === 'tools') {
newParams.delete('tab');
} else {
newParams.set('tab', newTab);
}
const queryString = newParams.toString();
router.replace(`/dashboard/collections/${collectionId}${queryString ? `?${queryString}` : ''}`, {
scroll: false,
});
};
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Fetch callback with error handling
const fetchCollection = useCallback(async () => {
try {
const response = await fetch(`/api/collections/${collectionId}`);
@ -214,7 +139,6 @@ export default function CollectionDetailPage(): React.ReactElement {
if (data.success) {
setCollection(data.data);
// Initialize executor config state from collection data
if (data.data.executorType === 'custom_url' && data.data.executorConfig) {
setExecutorConfig({
type: 'custom_url',
@ -224,7 +148,6 @@ export default function CollectionDetailPage(): React.ReactElement {
} else {
setExecutorConfig(data.data.executorType ? { type: 'default' } : null);
}
// Initialize env vars from collection data
if (data.data.envVars && typeof data.data.envVars === 'object') {
setEnvVars(data.data.envVars as Record<string, string>);
} else {
@ -253,12 +176,10 @@ export default function CollectionDetailPage(): React.ReactElement {
fetchCollection();
}, [fetchCollection]);
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Multiple conditional fields in update payload
const handleUpdate = async (data: { name: string; description?: string; isPublic: boolean }) => {
if (!collection) return;
setIsUpdating(true);
// Build update payload including executor config
const updatePayload: Record<string, unknown> = { ...data };
if (executorConfig) {
updatePayload.executorType = executorConfig.type;
@ -274,8 +195,6 @@ export default function CollectionDetailPage(): React.ReactElement {
updatePayload.executorType = null;
updatePayload.executorConfig = null;
}
// Add env vars
updatePayload.envVars = envVars;
try {
@ -301,7 +220,6 @@ export default function CollectionDetailPage(): React.ReactElement {
}
: null
);
setIsEditing(false);
} else {
throw new Error(result.error?.message || 'Failed to update collection');
}
@ -444,133 +362,347 @@ export default function CollectionDetailPage(): React.ReactElement {
}
const existingToolIds = collection.tools.map((t) => t.toolId);
const baseUrl = typeof window !== 'undefined' ? window.location.origin : 'https://tpmjs.com';
const httpUrl = `${baseUrl}/api/mcp/${collection.user.username}/${collection.slug}/http`;
const sseUrl = `${baseUrl}/api/mcp/${collection.user.username}/${collection.slug}/sse`;
const configSnippet = `{
"mcpServers": {
"${collection.slug}": {
"command": "npx",
"args": [
"mcp-remote",
"${httpUrl}",
"--header",
"Authorization: Bearer YOUR_TPMJS_API_KEY"
]
}
}
}`;
const envVarsCount = envVars ? Object.keys(envVars).length : 0;
const tabs = [
{ id: 'tools' as const, label: 'Tools', count: collection.toolCount },
{ id: 'connect' as const, label: 'Connect' },
{ id: 'env-vars' as const, label: 'Env Vars', count: envVarsCount > 0 ? envVarsCount : undefined },
{ id: 'settings' as const, label: 'Settings' },
];
return (
<DashboardLayout
title={isEditing ? 'Edit Collection' : collection.name}
subtitle={
!isEditing
? `${collection.toolCount} ${collection.toolCount === 1 ? 'tool' : 'tools'}${collection.isPublic ? ' • Public' : ''}`
: undefined
}
title={collection.name}
subtitle={collection.description || undefined}
showBackButton
backUrl="/dashboard/collections"
actions={
collection.isOwner &&
!isEditing && (
<div className="flex gap-2">
<Button variant="ghost" size="sm" onClick={() => setIsEditing(true)}>
<Icon icon="edit" size="sm" className="mr-1" />
Edit
</Button>
<Button
variant="ghost"
size="sm"
onClick={handleDelete}
loading={isDeleting}
disabled={isDeleting}
className="text-error hover:text-error hover:bg-error/10"
>
<Icon icon="trash" size="sm" className="mr-1" />
Delete
</Button>
</div>
collection.isOwner && (
<Button
variant="ghost"
size="sm"
onClick={handleDelete}
loading={isDeleting}
disabled={isDeleting}
className="text-error hover:text-error hover:bg-error/10"
>
<Icon icon="trash" size="sm" className="mr-1" />
Delete
</Button>
)
}
>
{/* Edit Form */}
{isEditing && (
<div className="bg-surface border border-border rounded-lg p-6 mb-8">
<CollectionForm
initialData={{
name: collection.name,
description: collection.description,
isPublic: collection.isPublic,
}}
onSubmit={handleUpdate}
onCancel={() => setIsEditing(false)}
isSubmitting={isUpdating}
submitLabel="Save Changes"
/>
{/* Status badges */}
<div className="flex items-center gap-2 mb-6">
<Badge variant={collection.isPublic ? 'success' : 'secondary'}>
{collection.isPublic ? 'Public' : 'Private'}
</Badge>
{executorConfig?.type === 'custom_url' && <Badge variant="secondary">Custom Executor</Badge>}
</div>
{/* Executor Configuration */}
<div className="mt-6 pt-6 border-t border-border">
<ExecutorConfigPanel
value={executorConfig}
onChange={setExecutorConfig}
disabled={isUpdating}
{/* Tabs */}
<Tabs
tabs={tabs}
activeTab={activeTab}
onTabChange={handleTabChange}
className="mb-6"
/>
{/* Tools Tab */}
{activeTab === 'tools' && (
<div>
{/* Add Tools Search */}
{collection.isOwner && (
<div className="mb-6">
<AddToolSearch
collectionId={collection.id}
existingToolIds={existingToolIds}
onToolAdded={handleToolAdded}
/>
</div>
)}
{/* Tools Table */}
<div className="bg-surface border border-border rounded-lg overflow-hidden">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[250px]">Tool</TableHead>
<TableHead>Description</TableHead>
<TableHead className="w-[120px]">Category</TableHead>
{collection.isOwner && <TableHead className="w-[80px] text-right">Actions</TableHead>}
</TableRow>
</TableHeader>
<TableBody>
{collection.tools.length === 0 ? (
<TableEmpty
colSpan={collection.isOwner ? 4 : 3}
icon={
<div className="w-16 h-16 rounded-full bg-primary/10 flex items-center justify-center">
<Icon icon="puzzle" size="lg" className="text-primary" />
</div>
}
title="No tools yet"
description="Search and add tools to this collection to get started."
/>
) : (
collection.tools.map((ct) => (
<TableRow key={ct.id}>
<TableCell>
<Link
href={`/tool/${ct.tool.package.npmPackageName}/${ct.tool.name}`}
className="font-medium text-foreground hover:text-primary transition-colors"
>
{ct.tool.name}
</Link>
<div className="text-xs text-foreground-tertiary mt-0.5">
{ct.tool.package.npmPackageName}
</div>
</TableCell>
<TableCell>
<span className="text-sm text-foreground-secondary line-clamp-2">
{ct.tool.description}
</span>
</TableCell>
<TableCell>
<Badge variant="secondary" size="sm">
{ct.tool.package.category}
</Badge>
</TableCell>
{collection.isOwner && (
<TableCell>
<div className="flex justify-end">
<Button
variant="ghost"
size="sm"
onClick={() => handleRemoveTool(ct.toolId)}
disabled={removingToolId === ct.toolId}
className="text-error hover:text-error hover:bg-error/10"
>
<Icon icon="trash" size="xs" />
</Button>
</div>
</TableCell>
)}
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</div>
)}
{/* Connect Tab */}
{activeTab === 'connect' && (
<div className="space-y-6">
{/* Username warning */}
{collection.isPublic && !collection.user.username && (
<div className="p-4 bg-warning/10 border border-warning/30 rounded-lg">
<div className="flex items-start gap-3">
<Icon icon="alertCircle" size="sm" className="text-warning mt-0.5" />
<div>
<h3 className="font-medium text-foreground">Set your username to enable MCP</h3>
<p className="text-sm text-foreground-secondary mt-1">
You need to set a username before you can share this collection as an MCP server.
</p>
<Link href="/dashboard/settings/profile" className="inline-block mt-3">
<Button size="sm" variant="secondary">
<Icon icon="user" size="xs" className="mr-1" />
Set Username
</Button>
</Link>
</div>
</div>
</div>
)}
{/* Private collection notice */}
{!collection.isPublic && (
<div className="p-4 bg-primary/5 border border-primary/20 rounded-lg">
<div className="flex items-start gap-3">
<Icon icon="info" size="sm" className="text-primary mt-0.5" />
<div>
<h3 className="font-medium text-foreground">Private Collection</h3>
<p className="text-sm text-foreground-secondary mt-1">
Make this collection public in Settings to get shareable MCP server URLs.
</p>
</div>
</div>
</div>
)}
{/* MCP URLs */}
{collection.isPublic && collection.user.username && (
<div className="bg-surface border border-border rounded-lg p-6">
<div className="flex items-center gap-2 mb-4">
<div className="p-1.5 bg-primary/10 rounded-lg">
<Icon icon="link" size="sm" className="text-primary" />
</div>
<h3 className="font-semibold text-foreground">MCP Server URLs</h3>
</div>
<div className="space-y-4">
<McpUrlDisplay url={httpUrl} label="HTTP Transport" sublabel="recommended" />
<McpUrlDisplay url={sseUrl} label="SSE Transport" sublabel="streaming" />
</div>
<div className="mt-6 pt-4 border-t border-border">
<button
type="button"
onClick={() => setShowClaudeConfig(!showClaudeConfig)}
className="flex items-center gap-2 text-sm text-primary hover:text-primary/80 transition-colors"
>
<Icon icon={showClaudeConfig ? 'chevronDown' : 'chevronRight'} size="xs" />
<span>Show Claude Desktop config</span>
</button>
{showClaudeConfig && (
<div className="mt-3 relative">
<pre className="p-4 bg-surface-secondary border border-border rounded-lg text-xs font-mono text-foreground-secondary overflow-x-auto">
{configSnippet}
</pre>
<Button
variant="ghost"
size="sm"
onClick={() => navigator.clipboard.writeText(configSnippet)}
className="absolute top-2 right-2"
>
<Icon icon="copy" size="xs" />
</Button>
</div>
)}
</div>
<p className="mt-4 text-xs text-foreground-tertiary">
Use these URLs with{' '}
<Link href="/docs/tutorials/mcp" className="text-primary hover:underline">
Claude Desktop, Cursor, or any MCP client
</Link>
. Requires your{' '}
<Link href="/dashboard/settings/tpmjs-api-keys" className="text-primary hover:underline">
TPMJS API key
</Link>{' '}
for authentication.
</p>
</div>
)}
</div>
)}
{/* Env Vars Tab */}
{activeTab === 'env-vars' && collection.isOwner && (
<div className="bg-surface border border-border rounded-lg p-6">
<EnvVarsEditor
value={envVars}
onChange={(newEnvVars) => {
setEnvVars(newEnvVars);
// Auto-save env vars
fetch(`/api/collections/${collectionId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ envVars: newEnvVars }),
});
}}
title="Environment Variables"
description="These variables are passed to tools at runtime. Use them to store API keys and configuration."
disabled={isUpdating}
/>
</div>
)}
{/* Env Vars Tab - Not Owner */}
{activeTab === 'env-vars' && !collection.isOwner && (
<div className="text-center py-16">
<Icon icon="lock" size="lg" className="mx-auto text-foreground-tertiary mb-4" />
<h2 className="text-lg font-medium text-foreground mb-2">Environment Variables Unavailable</h2>
<p className="text-foreground-secondary">
You can only view environment variables for collections you own.
</p>
</div>
)}
{/* Settings Tab */}
{activeTab === 'settings' && collection.isOwner && (
<div className="space-y-6">
{/* Collection Info */}
<div className="bg-surface border border-border rounded-lg p-6">
<h3 className="text-lg font-medium text-foreground mb-4">Collection Details</h3>
<CollectionForm
initialData={{
name: collection.name,
description: collection.description,
isPublic: collection.isPublic,
}}
onSubmit={handleUpdate}
isSubmitting={isUpdating}
submitLabel="Save Changes"
/>
</div>
{/* Environment Variables */}
<EnvVarsEditor
value={envVars}
onChange={setEnvVars}
title="Environment Variables"
description="Passed to tools at runtime. Agent vars override collection vars."
disabled={isUpdating}
className="mt-6 pt-6 border-t border-border"
/>
</div>
)}
{/* Description */}
{!isEditing && collection.description && (
<p className="text-foreground-secondary mb-8">{collection.description}</p>
)}
{/* MCP URLs - only for public collections */}
{collection.isPublic && collection.user.username && (
<McpUrlSection username={collection.user.username} slug={collection.slug} />
)}
{/* Prompt to set username if not set */}
{collection.isPublic && !collection.user.username && (
<div className="mb-8 p-4 bg-amber-50 border border-amber-200 rounded-xl">
<div className="flex items-start gap-3">
<Icon icon="alertCircle" size="sm" className="text-amber-600 mt-0.5" />
<div>
<h3 className="font-semibold text-amber-900">Set your username to enable MCP</h3>
<p className="text-sm text-amber-700 mt-1">
You need to set a username before you can share this collection as an MCP server.
Your MCP endpoint URL will be:{' '}
<code className="bg-amber-100 px-1 rounded">
tpmjs.com/api/mcp/your-username/{collection.slug}/http
</code>
</p>
<Link href="/dashboard/settings/profile" className="inline-block mt-3">
<Button size="sm" variant="secondary">
<Icon icon="user" size="xs" className="mr-1" />
Set Username
</Button>
</Link>
</div>
{/* Executor Configuration */}
<div className="bg-surface border border-border rounded-lg p-6">
<ExecutorConfigPanel
value={executorConfig}
onChange={(newConfig) => {
setExecutorConfig(newConfig);
// Auto-save executor config
const updatePayload: Record<string, unknown> = {};
if (newConfig) {
updatePayload.executorType = newConfig.type;
if (newConfig.type === 'custom_url') {
updatePayload.executorConfig = {
url: newConfig.url,
apiKey: newConfig.apiKey,
};
} else {
updatePayload.executorConfig = null;
}
} else {
updatePayload.executorType = null;
updatePayload.executorConfig = null;
}
fetch(`/api/collections/${collectionId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updatePayload),
});
}}
disabled={isUpdating}
/>
</div>
</div>
)}
{/* Add Tool Search */}
{collection.isOwner && (
<div className="mb-6">
<h2 className="text-sm font-medium text-foreground mb-2">Add Tools</h2>
<AddToolSearch
collectionId={collection.id}
existingToolIds={existingToolIds}
onToolAdded={handleToolAdded}
/>
{/* Settings Tab - Not Owner */}
{activeTab === 'settings' && !collection.isOwner && (
<div className="text-center py-16">
<Icon icon="lock" size="lg" className="mx-auto text-foreground-tertiary mb-4" />
<h2 className="text-lg font-medium text-foreground mb-2">Settings Unavailable</h2>
<p className="text-foreground-secondary">
You can only view settings for collections you own.
</p>
</div>
)}
{/* Tools List */}
<div>
<h2 className="text-sm font-medium text-foreground mb-3">Tools in this Collection</h2>
<CollectionToolList
tools={collection.tools}
onRemove={collection.isOwner ? handleRemoveTool : undefined}
removingId={removingToolId}
isOwner={collection.isOwner}
/>
</div>
</DashboardLayout>
);
}