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:
3 files changed
+62
-28
No files matched your search
@@ -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>
|
||||
)}
|
||||
|
||||
Reference in new issue
Block a user