From 757d59854e5ec82d91c053b8bc3d7eaf2e70efc4 Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Sat, 27 Jun 2026 10:58:35 +0200 Subject: [PATCH] fix: address PR review - atomic upsert, MCP metadata, stale tokens, save UX - Wrap setSecret delete+insert in a cross-DB transaction - Add MCP metadata to all widget secret procedures and register in mcp.ts - Show configured tokens even when no repos use that provider - Only clear token input after mutation succeeds, with loading state --- packages/api/src/mcp.ts | 2 + .../api/src/router/widgets/widget-secrets.ts | 36 +++++++++---- ...widget-multiReleasesRepositories-input.tsx | 52 ++++++++++++------- 3 files changed, 62 insertions(+), 28 deletions(-) diff --git a/packages/api/src/mcp.ts b/packages/api/src/mcp.ts index 887e878ad..b39e4a97d 100644 --- a/packages/api/src/mcp.ts +++ b/packages/api/src/mcp.ts @@ -17,6 +17,7 @@ import { healthMonitoringRouter } from "./router/widgets/health-monitoring"; import { mediaRequestsRouter } from "./router/widgets/media-requests"; import { mediaServerRouter } from "./router/widgets/media-server"; import { smartHomeRouter } from "./router/widgets/smart-home"; +import { widgetSecretsRouter } from "./router/widgets/widget-secrets"; export const mcpRouter = createTRPCRouter({ app: appRouterForApps, @@ -35,4 +36,5 @@ export const mcpRouter = createTRPCRouter({ mediaRequests: mediaRequestsRouter, mediaServer: mediaServerRouter, smartHome: smartHomeRouter, + widgetSecrets: widgetSecretsRouter, }); diff --git a/packages/api/src/router/widgets/widget-secrets.ts b/packages/api/src/router/widgets/widget-secrets.ts index 453681ddd..5b455ba4d 100644 --- a/packages/api/src/router/widgets/widget-secrets.ts +++ b/packages/api/src/router/widgets/widget-secrets.ts @@ -2,7 +2,7 @@ import { TRPCError } from "@trpc/server"; import { z } from "zod/v4"; import { encryptSecret } from "@homarr/common/server"; -import { and, eq } from "@homarr/db"; +import { and, eq, handleTransactionsAsync } from "@homarr/db"; import { boards, items, widgetSecrets } from "@homarr/db/schema"; import { releaseProviderKinds } from "@homarr/definitions"; @@ -20,23 +20,36 @@ const resolveItemWithBoardAccess = async (ctx: { db: any; session: any }, itemId export const widgetSecretsRouter = createTRPCRouter({ setSecret: protectedProcedure + .meta({ mcp: { enabled: true, description: "Set an authentication token for a release provider on a specific releases widget. Requires modify permission on the widget's board. Use widget_secrets_getConfiguredKinds to check existing tokens" } }) .input(z.object({ itemId: z.string(), kind: secretKindSchema, value: z.string().min(1).max(4096) })) .mutation(async ({ ctx, input }) => { await resolveItemWithBoardAccess(ctx, input.itemId); - await ctx.db - .delete(widgetSecrets) - .where(and(eq(widgetSecrets.itemId, input.itemId), eq(widgetSecrets.kind, input.kind))); + const encrypted = encryptSecret(input.value); + const row = { itemId: input.itemId, kind: input.kind, value: encrypted, updatedAt: new Date() }; - await ctx.db.insert(widgetSecrets).values({ - itemId: input.itemId, - kind: input.kind, - value: encryptSecret(input.value), - updatedAt: new Date(), + await handleTransactionsAsync(ctx.db, { + async handleAsync(db, schema) { + await db.transaction(async (tx) => { + await tx + .delete(schema.widgetSecrets) + .where(and(eq(schema.widgetSecrets.itemId, input.itemId), eq(schema.widgetSecrets.kind, input.kind))); + await tx.insert(schema.widgetSecrets).values(row); + }); + }, + handleSync(db) { + db.transaction((tx) => { + tx.delete(widgetSecrets) + .where(and(eq(widgetSecrets.itemId, input.itemId), eq(widgetSecrets.kind, input.kind))) + .run(); + tx.insert(widgetSecrets).values(row).run(); + }); + }, }); }), deleteSecret: protectedProcedure + .meta({ mcp: { enabled: true, description: "Remove an authentication token for a release provider from a specific releases widget. Requires modify permission on the widget's board" } }) .input(z.object({ itemId: z.string(), kind: secretKindSchema })) .mutation(async ({ ctx, input }) => { await resolveItemWithBoardAccess(ctx, input.itemId); @@ -46,7 +59,10 @@ export const widgetSecretsRouter = createTRPCRouter({ .where(and(eq(widgetSecrets.itemId, input.itemId), eq(widgetSecrets.kind, input.kind))); }), - getConfiguredKinds: protectedProcedure.input(z.object({ itemId: z.string() })).query(async ({ ctx, input }) => { + getConfiguredKinds: protectedProcedure + .meta({ mcp: { enabled: true, description: "List which release provider kinds have tokens configured for a releases widget. Returns an array of provider kind strings (e.g. github, gitlab, dockerHub). Requires modify permission on the widget's board" } }) + .input(z.object({ itemId: z.string() })) + .query(async ({ ctx, input }) => { await resolveItemWithBoardAccess(ctx, input.itemId); const secrets = await ctx.db.query.widgetSecrets.findMany({ diff --git a/packages/widgets/src/_inputs/widget-multiReleasesRepositories-input.tsx b/packages/widgets/src/_inputs/widget-multiReleasesRepositories-input.tsx index 46ad757d5..7fa7182ae 100644 --- a/packages/widgets/src/_inputs/widget-multiReleasesRepositories-input.tsx +++ b/packages/widgets/src/_inputs/widget-multiReleasesRepositories-input.tsx @@ -272,12 +272,14 @@ const ProviderTokensSection = ({ itemId, repositories }: { itemId: string; repos const setSecret = clientApi.widget.secrets.setSecret.useMutation({ onSuccess: () => refetch() }); const deleteSecret = clientApi.widget.secrets.deleteSecret.useMutation({ onSuccess: () => refetch() }); - const usedProviders = useMemo( - () => [...new Set(repositories.map((r) => r.provider).filter((p): p is ReleaseProviderKind => p !== undefined))], - [repositories], - ); + const authProviders = useMemo(() => { + const usedProviders = repositories.map((r) => r.provider).filter((p): p is ReleaseProviderKind => p !== undefined); + const configuredProviders = configuredKinds.filter((k): k is ReleaseProviderKind => + providersWithAuth.includes(k as ReleaseProviderKind), + ); + return [...new Set([...usedProviders, ...configuredProviders])].filter((p) => providersWithAuth.includes(p)); + }, [repositories, configuredKinds]); - const authProviders = usedProviders.filter((p) => providersWithAuth.includes(p)); if (authProviders.length === 0) return null; return ( @@ -289,8 +291,12 @@ const ProviderTokensSection = ({ itemId, repositories }: { itemId: string; repos key={provider} provider={provider} hasToken={configuredKinds.includes(provider)} - onSave={(value) => setSecret.mutate({ itemId, kind: provider, value })} - onDelete={() => deleteSecret.mutate({ itemId, kind: provider })} + onSave={async (value) => { + await setSecret.mutateAsync({ itemId, kind: provider, value }); + }} + onDelete={async () => { + await deleteSecret.mutateAsync({ itemId, kind: provider }); + }} /> ))} @@ -306,25 +312,35 @@ const ProviderTokenInput = ({ }: { provider: ReleaseProviderKind; hasToken: boolean; - onSave: (value: string) => void; - onDelete: () => void; + onSave: (value: string) => Promise; + onDelete: () => Promise; }) => { const tRepository = useScopedI18n("widget.releases.option.repositories"); const [value, setValue] = useState(""); const [editing, setEditing] = useState(false); + const [saving, setSaving] = useState(false); - const handleSave = () => { - if (value.trim()) { - onSave(value.trim()); + const handleSave = async () => { + if (!value.trim()) return; + setSaving(true); + try { + await onSave(value.trim()); setValue(""); setEditing(false); + } finally { + setSaving(false); } }; - const handleDelete = () => { - onDelete(); - setValue(""); - setEditing(false); + const handleDelete = async () => { + setSaving(true); + try { + await onDelete(); + setValue(""); + setEditing(false); + } finally { + setSaving(false); + } }; return ( @@ -342,12 +358,12 @@ const ProviderTokenInput = ({ leftSection={} /> {editing && value.trim() && ( - )} {hasToken && !editing && ( - + )}