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
This commit is contained in:
Thomas Camlong committed 2026-06-27 10:58:35 +02:00
1 parent 2155f13f24
commit 757d59854e
3 files changed
+62 -28

No files matched your search

+2
View File
@@ -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,
});
@@ -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({
@@ -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 });
}}
/>
))}
</Stack>
@@ -306,25 +312,35 @@ const ProviderTokenInput = ({
}: {
provider: ReleaseProviderKind;
hasToken: boolean;
onSave: (value: string) => void;
onDelete: () => void;
onSave: (value: string) => Promise<void>;
onDelete: () => Promise<void>;
}) => {
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={<IconKey size={14} />}
/>
{editing && value.trim() && (
<Button size="xs" onClick={handleSave}>
<Button size="xs" onClick={handleSave} loading={saving}>
{tRepository("tokens.save")}
</Button>
)}
{hasToken && !editing && (
<ActionIcon variant="light" color="red" size="sm" onClick={handleDelete}>
<ActionIcon variant="light" color="red" size="sm" onClick={handleDelete} loading={saving}>
<IconTrash size={14} />
</ActionIcon>
)}