mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-08-09 04:40:57 +00:00
e1ac5e0fc8
* Document multi-tenant workspace architecture * Add OSS and commercial workspace boundaries * docs: redesign multi-tenant workspace architecture * feat(tenancy): implement workspace isolation * docs(tenancy): record verification evidence * docs(tenancy): revise single-instance SaaS topology * docs(tenancy): refine architecture options * docs: finalize cloud v2 multi-tenant decisions * feat(tenancy): establish cloud isolation foundations * feat(tenancy): harden shared cloud runtime boundaries * docs(tenancy): record final isolation verification * fix(tenancy): close isolation and permission gaps * docs(tenancy): record final isolation verification * feat(tenancy): connect cloud workspace control plane * fix(build): install git for pinned SDK * docs(cloud): update control plane verification * chore: update multi-tenant SDK pin * fix(cloud): skip legacy model sync during startup * test(cloud): preserve minimal model manager fixtures * fix(cloud): preserve authenticated account context * fix(cloud): reuse authenticated account for user info * feat(cloud): complete Workspace settings navigation * test(web): cover Workspace dropdown menu * feat(web): place workspace controls in sidebar * refactor(web): streamline workspace controls * style(web): format workspace layout test * fix(cloud): surface runtime and workspace plan status * fix(plugin): keep runtime identity stable across restarts * fix(ui): widen and center workspace switcher * fix(ui): hide roles from workspace switcher * fix(ui): align workspace switcher with sidebar entries * feat(workspace): add in-product collaboration and direct Cloud launch * style: format collaboration changes * fix(workspace): bind collaboration APIs to tenant UoW * fix(cloud): preserve Core-owned collaboration state * test(cloud): require Space identity for invite registration * feat(cloud): complete secure invitation experience * style(web): format invitation flows * fix(cloud): recover box runtime without unscoped skill reload * feat(oss): enforce invitation account and owner billing flows * style: format OSS account service * test(oss): cover invitation logout handoff * fix(oss): resolve workspace owner in scoped session * feat(cloud): harden multi-tenant runtime resources * fix(cloud): bound runtime restart storms * fix(cloud): eliminate periodic runtime CPU spikes * fix(cloud): enforce instance capacity ceilings * fix(cloud): scope public login capability discovery * fix(cloud): bound tenant maintenance and monitoring work * fix(runtime): bound tenant resource amplification * fix(deps): pin green multi-tenant plugin SDK * fix(cloud): handle unavailable skill capability * fix(security): require authentication for image file endpoint (H-2) - Changed /api/v1/files/image from AuthType.NONE to USER_TOKEN_OR_API_KEY - Added Permission.RESOURCE_VIEW requirement - Prevents unauthenticated cross-tenant file access via leaked keys - Fixes HIGH severity finding from multi-tenant security review docs: add comprehensive database migration guide - Complete migration steps for OSS → multi-tenant - Backup, execution, verification procedures - Rollback scenarios and recovery plans - Performance tuning recommendations * test: add comprehensive cross-tenant isolation tests Added 7 critical test scenarios for multi-tenant boundaries: - Cross-tenant bot access prevention - Viewer role read-only enforcement - Removed member immediate access revocation - Model provider credential isolation - WebSocket message isolation - Invitation token workspace scoping - Multi-workspace context validation These tests address P0-2 coverage gaps for: - workspaces.py (membership & invitation flows) - user.py (authentication & authorization) - websocket_chat.py (real-time isolation) - plugins.py (resource access control) docs: finalize database migration guide * fix(security): resolve M-1, M-2, M-3 security findings M-1: WebSocket authorization TOCTOU race (FIXED) - Changed _revalidate_websocket_authorization to return RequestContext - Ensures validated context is used immediately without race window - Prevents removed members from sending messages during revalidation gap M-2: Model Manager cache workspace isolation (VERIFIED) - Confirmed _CacheKey already uses 4-tuple: (instance, workspace, generation, resource) - Cache is properly scoped per workspace, no cross-tenant leakage possible - No code change needed, documented as working correctly M-3: Invitation lock workspace scoping (FIXED) - Changed lock key from token_digest to workspace_uuid:token_digest - Prevents DoS where attacker locks token in Workspace A to block Workspace B - Locks now isolated per workspace All MEDIUM severity findings from security review now resolved. * fix(cloud): unblock tenant CI and enforce knowledge quotas * fix(tenancy): scope rerank model sync --------- Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
728 lines
24 KiB
TypeScript
728 lines
24 KiB
TypeScript
import * as React from 'react';
|
|
import { useState, useEffect, useRef } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { toast } from 'sonner';
|
|
import { Copy, Check, Trash2, Plus } from 'lucide-react';
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
DialogFooter,
|
|
DialogDescription,
|
|
} from '@/components/ui/dialog';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
|
import { Switch } from '@/components/ui/switch';
|
|
import {
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableHead,
|
|
TableHeader,
|
|
TableRow,
|
|
} from '@/components/ui/table';
|
|
import {
|
|
AlertDialog,
|
|
AlertDialogAction,
|
|
AlertDialogCancel,
|
|
AlertDialogDescription,
|
|
AlertDialogFooter,
|
|
AlertDialogHeader,
|
|
AlertDialogTitle,
|
|
AlertDialogPortal,
|
|
AlertDialogOverlay,
|
|
} from '@/components/ui/alert-dialog';
|
|
import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog';
|
|
import { backendClient } from '@/app/infra/http';
|
|
import { PanelToolbar } from '../settings-dialog/panel-layout';
|
|
|
|
interface ApiKey {
|
|
id: number;
|
|
uuid: string;
|
|
name: string;
|
|
description: string;
|
|
scopes: string[];
|
|
status: 'active' | 'revoked';
|
|
secret_available: false;
|
|
created_at: string;
|
|
}
|
|
|
|
type CreatedApiKey = Omit<ApiKey, 'secret_available'> & {
|
|
key: string;
|
|
secret_available: true;
|
|
};
|
|
|
|
interface Webhook {
|
|
id: number;
|
|
name: string;
|
|
url: string;
|
|
description: string;
|
|
enabled: boolean;
|
|
created_at: string;
|
|
}
|
|
|
|
interface ApiIntegrationPanelProps {
|
|
// True when this panel is the active section and the dialog is open.
|
|
active: boolean;
|
|
}
|
|
|
|
export default function ApiIntegrationPanel({
|
|
active,
|
|
}: ApiIntegrationPanelProps) {
|
|
const { t } = useTranslation();
|
|
const [activeTab, setActiveTab] = useState('apikeys');
|
|
const [apiKeys, setApiKeys] = useState<ApiKey[]>([]);
|
|
const [webhooks, setWebhooks] = useState<Webhook[]>([]);
|
|
const [loading, setLoading] = useState(false);
|
|
const [showCreateDialog, setShowCreateDialog] = useState(false);
|
|
const [newKeyName, setNewKeyName] = useState('');
|
|
const [newKeyDescription, setNewKeyDescription] = useState('');
|
|
const [createdKey, setCreatedKey] = useState<CreatedApiKey | null>(null);
|
|
const [deleteKeyId, setDeleteKeyId] = useState<number | null>(null);
|
|
|
|
// Webhook state
|
|
const [showCreateWebhookDialog, setShowCreateWebhookDialog] = useState(false);
|
|
const [newWebhookName, setNewWebhookName] = useState('');
|
|
const [newWebhookUrl, setNewWebhookUrl] = useState('');
|
|
const [newWebhookDescription, setNewWebhookDescription] = useState('');
|
|
const [newWebhookEnabled, setNewWebhookEnabled] = useState(true);
|
|
const [deleteWebhookId, setDeleteWebhookId] = useState<number | null>(null);
|
|
const copiedTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(
|
|
undefined,
|
|
);
|
|
const [copiedKey, setCopiedKey] = useState<string | null>(null);
|
|
|
|
// MCP server endpoint, derived from the current origin. The backend serves
|
|
// the MCP server at /mcp on the same host/port as the HTTP API + web UI.
|
|
const mcpEndpoint =
|
|
typeof window !== 'undefined' ? `${window.location.origin}/mcp` : '/mcp';
|
|
|
|
// 清理 body 样式,防止嵌套对话框关闭后页面无法交互
|
|
useEffect(() => {
|
|
if (!deleteKeyId && !deleteWebhookId) {
|
|
const cleanup = () => {
|
|
document.body.style.removeProperty('pointer-events');
|
|
};
|
|
|
|
cleanup();
|
|
const timer = setTimeout(cleanup, 100);
|
|
return () => clearTimeout(timer);
|
|
}
|
|
}, [deleteKeyId, deleteWebhookId]);
|
|
|
|
useEffect(() => {
|
|
if (active) {
|
|
loadApiKeys();
|
|
loadWebhooks();
|
|
}
|
|
}, [active]);
|
|
|
|
const loadApiKeys = async () => {
|
|
setLoading(true);
|
|
try {
|
|
const response = (await backendClient.get('/api/v1/apikeys')) as {
|
|
keys: ApiKey[];
|
|
};
|
|
setApiKeys(response.keys || []);
|
|
} catch (error) {
|
|
toast.error(`Failed to load API keys: ${error}`);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleCreateApiKey = async () => {
|
|
if (!newKeyName.trim()) {
|
|
toast.error(t('common.apiKeyNameRequired'));
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const response = (await backendClient.post('/api/v1/apikeys', {
|
|
name: newKeyName,
|
|
description: newKeyDescription,
|
|
})) as { key: CreatedApiKey };
|
|
|
|
setCreatedKey(response.key);
|
|
toast.success(t('common.apiKeyCreated'));
|
|
setNewKeyName('');
|
|
setNewKeyDescription('');
|
|
setShowCreateDialog(false);
|
|
loadApiKeys();
|
|
} catch (error) {
|
|
toast.error(`Failed to create API key: ${error}`);
|
|
}
|
|
};
|
|
|
|
const handleDeleteApiKey = async (keyId: number) => {
|
|
try {
|
|
await backendClient.delete(`/api/v1/apikeys/${keyId}`);
|
|
toast.success(t('common.apiKeyDeleted'));
|
|
loadApiKeys();
|
|
setDeleteKeyId(null);
|
|
} catch (error) {
|
|
toast.error(`Failed to delete API key: ${error}`);
|
|
}
|
|
};
|
|
|
|
const copyToClipboard = (text: string) => {
|
|
const el = document.createElement('span');
|
|
el.textContent = text;
|
|
el.style.cssText =
|
|
'position:fixed;opacity:0;pointer-events:none;white-space:pre;';
|
|
document.body.appendChild(el);
|
|
const range = document.createRange();
|
|
range.selectNodeContents(el);
|
|
const sel = window.getSelection();
|
|
sel?.removeAllRanges();
|
|
sel?.addRange(range);
|
|
document.execCommand('copy');
|
|
sel?.removeAllRanges();
|
|
document.body.removeChild(el);
|
|
};
|
|
|
|
const handleCopyKey = (key: string) => {
|
|
try {
|
|
copyToClipboard(key);
|
|
} catch {}
|
|
clearTimeout(copiedTimerRef.current);
|
|
setCopiedKey(key);
|
|
copiedTimerRef.current = setTimeout(() => setCopiedKey(null), 2000);
|
|
};
|
|
|
|
// Webhook methods
|
|
const loadWebhooks = async () => {
|
|
setLoading(true);
|
|
try {
|
|
const response = (await backendClient.get('/api/v1/webhooks')) as {
|
|
webhooks: Webhook[];
|
|
};
|
|
setWebhooks(response.webhooks || []);
|
|
} catch (error) {
|
|
toast.error(`Failed to load webhooks: ${error}`);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleCreateWebhook = async () => {
|
|
if (!newWebhookName.trim()) {
|
|
toast.error(t('common.webhookNameRequired'));
|
|
return;
|
|
}
|
|
if (!newWebhookUrl.trim()) {
|
|
toast.error(t('common.webhookUrlRequired'));
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await backendClient.post('/api/v1/webhooks', {
|
|
name: newWebhookName,
|
|
url: newWebhookUrl,
|
|
description: newWebhookDescription,
|
|
enabled: newWebhookEnabled,
|
|
});
|
|
|
|
toast.success(t('common.webhookCreated'));
|
|
setNewWebhookName('');
|
|
setNewWebhookUrl('');
|
|
setNewWebhookDescription('');
|
|
setNewWebhookEnabled(true);
|
|
setShowCreateWebhookDialog(false);
|
|
loadWebhooks();
|
|
} catch (error) {
|
|
toast.error(`Failed to create webhook: ${error}`);
|
|
}
|
|
};
|
|
|
|
const handleDeleteWebhook = async (webhookId: number) => {
|
|
try {
|
|
await backendClient.delete(`/api/v1/webhooks/${webhookId}`);
|
|
toast.success(t('common.webhookDeleted'));
|
|
loadWebhooks();
|
|
setDeleteWebhookId(null);
|
|
} catch (error) {
|
|
toast.error(`Failed to delete webhook: ${error}`);
|
|
}
|
|
};
|
|
|
|
const handleToggleWebhook = async (webhook: Webhook) => {
|
|
try {
|
|
await backendClient.put(`/api/v1/webhooks/${webhook.id}`, {
|
|
enabled: !webhook.enabled,
|
|
});
|
|
loadWebhooks();
|
|
} catch (error) {
|
|
toast.error(`Failed to update webhook: ${error}`);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<Tabs
|
|
value={activeTab}
|
|
onValueChange={setActiveTab}
|
|
className="flex h-full min-h-0 w-full flex-col overflow-hidden"
|
|
>
|
|
<PanelToolbar>
|
|
<TabsList>
|
|
<TabsTrigger value="apikeys">{t('common.apiKeys')}</TabsTrigger>
|
|
<TabsTrigger value="webhooks">{t('common.webhooks')}</TabsTrigger>
|
|
<TabsTrigger value="mcp">{t('common.mcpTab')}</TabsTrigger>
|
|
</TabsList>
|
|
{activeTab === 'apikeys' ? (
|
|
<Button
|
|
onClick={() => setShowCreateDialog(true)}
|
|
size="sm"
|
|
className="gap-2"
|
|
>
|
|
<Plus className="h-4 w-4" />
|
|
{t('common.createApiKey')}
|
|
</Button>
|
|
) : activeTab === 'webhooks' ? (
|
|
<Button
|
|
onClick={() => setShowCreateWebhookDialog(true)}
|
|
size="sm"
|
|
className="gap-2"
|
|
>
|
|
<Plus className="h-4 w-4" />
|
|
{t('common.createWebhook')}
|
|
</Button>
|
|
) : null}
|
|
</PanelToolbar>
|
|
|
|
{/* API Keys Tab */}
|
|
<TabsContent
|
|
value="apikeys"
|
|
className="min-h-0 flex-1 space-y-4 overflow-auto px-6 py-5"
|
|
>
|
|
<p className="text-sm text-muted-foreground">
|
|
{t('common.apiKeyHint')}
|
|
</p>
|
|
|
|
{loading ? (
|
|
<div className="text-center py-8 text-muted-foreground">
|
|
{t('common.loading')}
|
|
</div>
|
|
) : apiKeys.length === 0 ? (
|
|
<div className="text-center py-8 text-muted-foreground">
|
|
{t('common.noApiKeys')}
|
|
</div>
|
|
) : (
|
|
<div className="flex-1 overflow-auto rounded-md border">
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead className="min-w-[120px]">
|
|
{t('common.name')}
|
|
</TableHead>
|
|
<TableHead className="min-w-[200px]">
|
|
{t('common.apiKeyValue')}
|
|
</TableHead>
|
|
<TableHead className="w-[100px]">
|
|
{t('common.actions')}
|
|
</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{apiKeys.map((item) => (
|
|
<TableRow key={item.id}>
|
|
<TableCell>
|
|
<div>
|
|
<div className="font-medium">{item.name}</div>
|
|
{item.description && (
|
|
<div className="text-sm text-muted-foreground">
|
|
{item.description}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</TableCell>
|
|
<TableCell>
|
|
<span className="text-sm text-muted-foreground">
|
|
{t('common.apiKeyStoredSecurely')}
|
|
</span>
|
|
</TableCell>
|
|
<TableCell>
|
|
<div className="flex gap-2">
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => setDeleteKeyId(item.id)}
|
|
title={t('common.delete')}
|
|
>
|
|
<Trash2 className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
)}
|
|
</TabsContent>
|
|
|
|
{/* Webhooks Tab */}
|
|
<TabsContent
|
|
value="webhooks"
|
|
className="min-h-0 flex-1 space-y-4 overflow-auto px-6 py-5"
|
|
>
|
|
<p className="text-sm text-muted-foreground">
|
|
{t('common.webhookHint')}
|
|
</p>
|
|
|
|
{loading ? (
|
|
<div className="text-center py-8 text-muted-foreground">
|
|
{t('common.loading')}
|
|
</div>
|
|
) : webhooks.length === 0 ? (
|
|
<div className="text-center py-8 text-muted-foreground">
|
|
{t('common.noWebhooks')}
|
|
</div>
|
|
) : (
|
|
<div className="max-w-full flex-1 overflow-auto rounded-md border">
|
|
<Table className="table-fixed w-full">
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead className="w-[150px]">
|
|
{t('common.name')}
|
|
</TableHead>
|
|
<TableHead className="w-[380px]">
|
|
{t('common.webhookUrl')}
|
|
</TableHead>
|
|
<TableHead className="w-[80px]">
|
|
{t('common.webhookEnabled')}
|
|
</TableHead>
|
|
<TableHead className="w-[80px]">
|
|
{t('common.actions')}
|
|
</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{webhooks.map((webhook) => (
|
|
<TableRow key={webhook.id}>
|
|
<TableCell className="truncate">
|
|
<div className="truncate">
|
|
<div
|
|
className="font-medium truncate"
|
|
title={webhook.name}
|
|
>
|
|
{webhook.name}
|
|
</div>
|
|
{webhook.description && (
|
|
<div
|
|
className="text-sm text-muted-foreground truncate"
|
|
title={webhook.description}
|
|
>
|
|
{webhook.description}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</TableCell>
|
|
<TableCell>
|
|
<div className="overflow-x-auto max-w-[380px]">
|
|
<code className="text-sm bg-muted px-2 py-1 rounded whitespace-nowrap inline-block">
|
|
{webhook.url}
|
|
</code>
|
|
</div>
|
|
</TableCell>
|
|
<TableCell>
|
|
<Switch
|
|
checked={webhook.enabled}
|
|
onCheckedChange={() => handleToggleWebhook(webhook)}
|
|
/>
|
|
</TableCell>
|
|
<TableCell>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => setDeleteWebhookId(webhook.id)}
|
|
title={t('common.delete')}
|
|
>
|
|
<Trash2 className="h-4 w-4" />
|
|
</Button>
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
)}
|
|
</TabsContent>
|
|
|
|
{/* MCP Tab */}
|
|
<TabsContent
|
|
value="mcp"
|
|
className="min-h-0 flex-1 space-y-4 overflow-auto px-6 py-5"
|
|
>
|
|
<p className="text-sm text-muted-foreground">{t('common.mcpHint')}</p>
|
|
|
|
<div className="space-y-2">
|
|
<label className="text-sm font-medium">
|
|
{t('common.mcpEndpoint')}
|
|
</label>
|
|
<div className="flex items-center gap-2">
|
|
<code className="flex-1 truncate rounded bg-muted px-3 py-2 text-sm">
|
|
{mcpEndpoint}
|
|
</code>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
type="button"
|
|
onClick={() => handleCopyKey(mcpEndpoint)}
|
|
title={t('common.copy')}
|
|
>
|
|
{copiedKey === mcpEndpoint ? (
|
|
<Check className="h-4 w-4 text-green-600" />
|
|
) : (
|
|
<Copy className="h-4 w-4" />
|
|
)}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<label className="text-sm font-medium">
|
|
{t('common.mcpAuthTitle')}
|
|
</label>
|
|
<p className="text-sm text-muted-foreground">
|
|
{t('common.mcpAuthDesc')}
|
|
</p>
|
|
<pre className="overflow-auto rounded bg-muted px-3 py-2 text-xs">
|
|
{`X-API-Key: <your-api-key>
|
|
# or
|
|
Authorization: Bearer <your-api-key>`}
|
|
</pre>
|
|
<p className="text-sm text-muted-foreground">
|
|
{t('common.mcpGlobalKeyNote')}
|
|
</p>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<label className="text-sm font-medium">
|
|
{t('common.mcpClientConfigTitle')}
|
|
</label>
|
|
<pre className="overflow-auto rounded bg-muted px-3 py-2 text-xs">
|
|
{`{
|
|
"mcpServers": {
|
|
"langbot": {
|
|
"url": "${mcpEndpoint}",
|
|
"headers": { "X-API-Key": "<your-api-key>" }
|
|
}
|
|
}
|
|
}`}
|
|
</pre>
|
|
</div>
|
|
</TabsContent>
|
|
</Tabs>
|
|
|
|
{/* Create API Key Dialog */}
|
|
<Dialog open={showCreateDialog} onOpenChange={setShowCreateDialog}>
|
|
<DialogContent className="sm:max-w-md">
|
|
<DialogHeader>
|
|
<DialogTitle>{t('common.createApiKey')}</DialogTitle>
|
|
</DialogHeader>
|
|
<div className="space-y-4">
|
|
<div>
|
|
<label className="text-sm font-medium">{t('common.name')}</label>
|
|
<Input
|
|
value={newKeyName}
|
|
onChange={(e) => setNewKeyName(e.target.value)}
|
|
placeholder={t('common.name')}
|
|
className="mt-1"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="text-sm font-medium">
|
|
{t('common.description')}
|
|
</label>
|
|
<Input
|
|
value={newKeyDescription}
|
|
onChange={(e) => setNewKeyDescription(e.target.value)}
|
|
placeholder={t('common.description')}
|
|
className="mt-1"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<DialogFooter>
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => setShowCreateDialog(false)}
|
|
>
|
|
{t('common.cancel')}
|
|
</Button>
|
|
<Button onClick={handleCreateApiKey}>{t('common.create')}</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* Show Created Key Dialog */}
|
|
<Dialog open={!!createdKey} onOpenChange={() => setCreatedKey(null)}>
|
|
<DialogContent className="sm:max-w-md">
|
|
<DialogHeader>
|
|
<DialogTitle>{t('common.apiKeyCreated')}</DialogTitle>
|
|
<DialogDescription>
|
|
{t('common.apiKeyCreatedMessage')}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="space-y-4">
|
|
<div>
|
|
<label className="text-sm font-medium">
|
|
{t('common.apiKeyValue')}
|
|
</label>
|
|
<div className="flex gap-2 mt-1">
|
|
<Input value={createdKey?.key || ''} readOnly />
|
|
<Button
|
|
onClick={() => createdKey && handleCopyKey(createdKey.key)}
|
|
variant="outline"
|
|
size="icon"
|
|
>
|
|
{copiedKey === createdKey?.key ? (
|
|
<Check className="h-4 w-4 text-green-600" />
|
|
) : (
|
|
<Copy className="h-4 w-4" />
|
|
)}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<DialogFooter>
|
|
<Button onClick={() => setCreatedKey(null)}>
|
|
{t('common.close')}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* Create Webhook Dialog */}
|
|
<Dialog
|
|
open={showCreateWebhookDialog}
|
|
onOpenChange={setShowCreateWebhookDialog}
|
|
>
|
|
<DialogContent className="sm:max-w-md">
|
|
<DialogHeader>
|
|
<DialogTitle>{t('common.createWebhook')}</DialogTitle>
|
|
</DialogHeader>
|
|
<div className="space-y-4">
|
|
<div>
|
|
<label className="text-sm font-medium">{t('common.name')}</label>
|
|
<Input
|
|
value={newWebhookName}
|
|
onChange={(e) => setNewWebhookName(e.target.value)}
|
|
placeholder={t('common.webhookName')}
|
|
className="mt-1"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="text-sm font-medium">
|
|
{t('common.webhookUrl')}
|
|
</label>
|
|
<Input
|
|
value={newWebhookUrl}
|
|
onChange={(e) => setNewWebhookUrl(e.target.value)}
|
|
placeholder="https://example.com/webhook"
|
|
className="mt-1"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="text-sm font-medium">
|
|
{t('common.description')}
|
|
</label>
|
|
<Input
|
|
value={newWebhookDescription}
|
|
onChange={(e) => setNewWebhookDescription(e.target.value)}
|
|
placeholder={t('common.description')}
|
|
className="mt-1"
|
|
/>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<Switch
|
|
checked={newWebhookEnabled}
|
|
onCheckedChange={setNewWebhookEnabled}
|
|
/>
|
|
<label className="text-sm font-medium">
|
|
{t('common.webhookEnabled')}
|
|
</label>
|
|
</div>
|
|
</div>
|
|
<DialogFooter>
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => setShowCreateWebhookDialog(false)}
|
|
>
|
|
{t('common.cancel')}
|
|
</Button>
|
|
<Button onClick={handleCreateWebhook}>{t('common.create')}</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* Delete Confirmation Dialog */}
|
|
<AlertDialog open={!!deleteKeyId}>
|
|
<AlertDialogPortal>
|
|
<AlertDialogOverlay
|
|
className="z-[60]"
|
|
onClick={() => setDeleteKeyId(null)}
|
|
/>
|
|
<AlertDialogPrimitive.Content
|
|
className="fixed left-[50%] top-[50%] z-[60] grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 sm:rounded-lg"
|
|
onEscapeKeyDown={() => setDeleteKeyId(null)}
|
|
>
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle>{t('common.confirmDelete')}</AlertDialogTitle>
|
|
<AlertDialogDescription>
|
|
{t('common.apiKeyDeleteConfirm')}
|
|
</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel onClick={() => setDeleteKeyId(null)}>
|
|
{t('common.cancel')}
|
|
</AlertDialogCancel>
|
|
<AlertDialogAction
|
|
onClick={() => deleteKeyId && handleDeleteApiKey(deleteKeyId)}
|
|
>
|
|
{t('common.delete')}
|
|
</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogPrimitive.Content>
|
|
</AlertDialogPortal>
|
|
</AlertDialog>
|
|
|
|
{/* Delete Webhook Confirmation Dialog */}
|
|
<AlertDialog open={!!deleteWebhookId}>
|
|
<AlertDialogPortal>
|
|
<AlertDialogOverlay
|
|
className="z-[60]"
|
|
onClick={() => setDeleteWebhookId(null)}
|
|
/>
|
|
<AlertDialogPrimitive.Content
|
|
className="fixed left-[50%] top-[50%] z-[60] grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 sm:rounded-lg"
|
|
onEscapeKeyDown={() => setDeleteWebhookId(null)}
|
|
>
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle>{t('common.confirmDelete')}</AlertDialogTitle>
|
|
<AlertDialogDescription>
|
|
{t('common.webhookDeleteConfirm')}
|
|
</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel onClick={() => setDeleteWebhookId(null)}>
|
|
{t('common.cancel')}
|
|
</AlertDialogCancel>
|
|
<AlertDialogAction
|
|
onClick={() =>
|
|
deleteWebhookId && handleDeleteWebhook(deleteWebhookId)
|
|
}
|
|
>
|
|
{t('common.delete')}
|
|
</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogPrimitive.Content>
|
|
</AlertDialogPortal>
|
|
</AlertDialog>
|
|
</>
|
|
);
|
|
}
|