chore: auto-commit (12 archivos)

- app.md
- backend/handlers.go
- backend/main.go
- frontend/src/App.tsx
- frontend/src/api.ts
- frontend/vite.config.ts
- backend/mcp_http.go
- backend/mcp_tokens.go
- backend/mcp_tokens_handlers.go
- backend/migrations/016_mcp_tokens.sql
- ...

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-22 14:38:17 +02:00
parent c9e15513c7
commit c28ae7d3c0
13 changed files with 771 additions and 4 deletions
+36 -3
View File
@@ -56,6 +56,7 @@ import {
IconLayoutKanban,
IconLogout,
IconPlug,
IconKey,
IconMenu2,
IconMessageChatbot,
IconMoodSmile,
@@ -84,6 +85,7 @@ import { AVATAR_COLORS } from "./components/colors";
import { colorBg, colorBorder } from "./components/colors";
import { NotificationsBell } from "./components/NotificationsBell";
import { ModulesModal } from "./components/ModulesModal";
import { MCPTokensModal } from "./components/MCPTokensModal";
import { useEventStream } from "./hooks/useEventStream";
import type { Board, Card, CardColor, Column, ColumnLocation, Notification, User } from "./types";
@@ -255,6 +257,23 @@ export function App() {
}
}, []);
// Coalesce ráfagas de board.invalidated (trailing debounce 300ms) — sin esto
// cada mutación remota dispara un refetch /api/board completo y la memoria
// del navegador crece sin techo.
const reloadTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const debouncedReload = useCallback(() => {
if (reloadTimerRef.current) clearTimeout(reloadTimerRef.current);
reloadTimerRef.current = setTimeout(() => {
reloadTimerRef.current = null;
reload();
}, 300);
}, [reload]);
useEffect(() => {
return () => {
if (reloadTimerRef.current) clearTimeout(reloadTimerRef.current);
};
}, []);
useEffect(() => {
reload();
}, [reload]);
@@ -344,6 +363,7 @@ export function App() {
}, []);
const [modulesOpen, setModulesOpen] = useState(false);
const [mcpTokensOpen, setMcpTokensOpen] = useState(false);
const reloadNotifs = useCallback(async () => {
try {
@@ -367,7 +387,7 @@ export function App() {
useMemo(
() => ({
"board.invalidated": () => {
reload();
debouncedReload();
},
"notification.created": (payload: unknown) => {
const n = payload as Notification;
@@ -377,6 +397,7 @@ export function App() {
const who = n.actor_name || "Alguien";
const card = n.card_seq_num ? `#${n.card_seq_num}` : n.card_title;
notifications.show({
autoClose: 4000,
color: n.kind === "mention" ? "grape" : "blue",
title: `${who} en ${card}`,
message: n.snippet,
@@ -393,7 +414,7 @@ export function App() {
setNotifUnread(0);
},
}),
[reload],
[debouncedReload],
),
!!auth.user,
);
@@ -428,16 +449,21 @@ export function App() {
(c: Card): boolean => {
const term = searchTerm.trim().toLowerCase();
if (term) {
const seqStr = c.seq_num > 0 ? String(c.seq_num) : "";
const seqPadded = c.seq_num > 0 ? String(c.seq_num).padStart(5, "0") : "";
const hay = [
c.title,
c.description,
c.requester,
seqStr,
seqPadded,
...(c.tags || []),
]
.filter(Boolean)
.join(" ")
.toLowerCase();
if (!hay.includes(term)) return false;
const normalizedTerm = term.replace(/^#/, "").replace(/^0+(?=\d)/, "");
if (!hay.includes(term) && !(normalizedTerm && hay.includes(normalizedTerm))) return false;
}
if (filterAssigneeId && c.assignee_id !== filterAssigneeId) return false;
if (filterUnassigned && c.assignee_id) return false;
@@ -1266,6 +1292,12 @@ export function App() {
Modulos
</Menu.Item>
)}
<Menu.Item
leftSection={<IconKey size={14} />}
onClick={() => setMcpTokensOpen(true)}
>
MCP tokens
</Menu.Item>
<Menu.Item
leftSection={<IconLogout size={14} />}
color="red"
@@ -1279,6 +1311,7 @@ export function App() {
{auth.user?.is_admin && (
<ModulesModal opened={modulesOpen} onClose={() => setModulesOpen(false)} />
)}
<MCPTokensModal opened={mcpTokensOpen} onClose={() => setMcpTokensOpen(false)} />
</Group>
</Group>
</AppShell.Header>
+23
View File
@@ -443,6 +443,29 @@ export function listRequesters(): Promise<string[]> {
return fetchJSON("/requesters");
}
export interface MCPToken {
id: string;
name: string;
created_at: string;
last_used_at?: string;
}
export interface MCPTokenCreated extends MCPToken {
token: string;
}
export function createMCPToken(name: string): Promise<MCPTokenCreated> {
return fetchJSON("/mcp-tokens", { method: "POST", body: JSON.stringify({ name }) });
}
export function listMCPTokens(): Promise<MCPToken[]> {
return fetchJSON("/mcp-tokens");
}
export function revokeMCPToken(id: string): Promise<void> {
return fetchJSON(`/mcp-tokens/${id}`, { method: "DELETE" });
}
export function getMetrics(f: MetricsFilter): Promise<Metrics> {
const qs = new URLSearchParams();
if (f.from) qs.set("from", f.from);
+192
View File
@@ -0,0 +1,192 @@
import {
ActionIcon,
Alert,
Box,
Button,
Code,
CopyButton,
Divider,
Group,
Loader,
Modal,
Stack,
Table,
Text,
TextInput,
Tooltip,
} from "@mantine/core";
import { notifications } from "@mantine/notifications";
import { IconCopy, IconCheck, IconTrash } from "@tabler/icons-react";
import { useCallback, useEffect, useState } from "react";
import * as api from "../api";
import type { MCPToken, MCPTokenCreated } from "../api";
import { formatDateTimeShort } from "./format";
interface Props {
opened: boolean;
onClose: () => void;
}
export function MCPTokensModal({ opened, onClose }: Props) {
const [tokens, setTokens] = useState<MCPToken[]>([]);
const [loading, setLoading] = useState(false);
const [newName, setNewName] = useState("");
const [creating, setCreating] = useState(false);
const [justCreated, setJustCreated] = useState<MCPTokenCreated | null>(null);
const reload = useCallback(async () => {
setLoading(true);
try {
setTokens(await api.listMCPTokens());
} catch (e) {
notifications.show({ color: "red", message: (e as Error).message });
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
if (opened) {
reload();
setJustCreated(null);
setNewName("");
}
}, [opened, reload]);
const create = async () => {
const name = newName.trim() || "default";
setCreating(true);
try {
const t = await api.createMCPToken(name);
setJustCreated(t);
setNewName("");
await reload();
} catch (e) {
notifications.show({ color: "red", message: (e as Error).message });
} finally {
setCreating(false);
}
};
const revoke = async (id: string) => {
if (!confirm("Revocar este token? Quien lo este usando dejara de tener acceso.")) return;
try {
await api.revokeMCPToken(id);
await reload();
} catch (e) {
notifications.show({ color: "red", message: (e as Error).message });
}
};
const mcpURL = `${window.location.origin}/mcp`;
const claudeCmd = justCreated
? `claude mcp add kanban --transport http ${mcpURL} --header "Authorization: Bearer ${justCreated.token}"`
: "";
return (
<Modal opened={opened} onClose={onClose} title="MCP Tokens" size="lg">
<Stack gap="md">
<Text size="sm" c="dimmed">
Cada token deja conectar un cliente Claude al kanban como tu usuario.
El valor solo aparece UNA vez al crearlo. Si lo pierdes, generas otro y revocas el antiguo.
</Text>
<Group align="end">
<TextInput
label="Nombre del token"
placeholder="ej. portatil, sobremesa..."
value={newName}
onChange={(e) => setNewName(e.currentTarget.value)}
style={{ flex: 1 }}
disabled={creating}
/>
<Button onClick={create} loading={creating}>
Generar
</Button>
</Group>
{justCreated && (
<Alert color="yellow" title="Copia el token ahora — no se mostrara mas">
<Stack gap="xs">
<Group gap="xs" align="center">
<Code style={{ flex: 1, wordBreak: "break-all" }}>{justCreated.token}</Code>
<CopyButton value={justCreated.token}>
{({ copied, copy }) => (
<Tooltip label={copied ? "Copiado" : "Copiar token"}>
<ActionIcon variant="subtle" onClick={copy}>
{copied ? <IconCheck size={16} /> : <IconCopy size={16} />}
</ActionIcon>
</Tooltip>
)}
</CopyButton>
</Group>
<Divider />
<Text size="xs" c="dimmed">
Pega este comando en tu PC para registrar el MCP en Claude Code:
</Text>
<Group gap="xs" align="center">
<Code block style={{ flex: 1 }}>{claudeCmd}</Code>
<CopyButton value={claudeCmd}>
{({ copied, copy }) => (
<Tooltip label={copied ? "Copiado" : "Copiar comando"}>
<ActionIcon variant="subtle" onClick={copy}>
{copied ? <IconCheck size={16} /> : <IconCopy size={16} />}
</ActionIcon>
</Tooltip>
)}
</CopyButton>
</Group>
</Stack>
</Alert>
)}
<Divider label="Tokens activos" labelPosition="left" />
{loading ? (
<Group justify="center" p="md">
<Loader size="sm" />
</Group>
) : tokens.length === 0 ? (
<Text size="sm" c="dimmed" ta="center" py="md">
Sin tokens. Genera uno arriba.
</Text>
) : (
<Table withTableBorder withColumnBorders verticalSpacing="xs" highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Nombre</Table.Th>
<Table.Th>Creado</Table.Th>
<Table.Th>Ultimo uso</Table.Th>
<Table.Th w={60} />
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{tokens.map((t) => (
<Table.Tr key={t.id}>
<Table.Td>{t.name}</Table.Td>
<Table.Td>{formatDateTimeShort(t.created_at)}</Table.Td>
<Table.Td>
{t.last_used_at ? formatDateTimeShort(t.last_used_at) : <Text c="dimmed">nunca</Text>}
</Table.Td>
<Table.Td>
<Tooltip label="Revocar">
<ActionIcon color="red" variant="subtle" onClick={() => revoke(t.id)}>
<IconTrash size={14} />
</ActionIcon>
</Tooltip>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
)}
<Box>
<Text size="xs" c="dimmed">
Endpoint MCP: <Code>{mcpURL}</Code>
</Text>
</Box>
</Stack>
</Modal>
);
}