From 77884a31794e12a6f7bc59d96c8f61aa429ca68d Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Fri, 26 Jun 2026 20:34:17 +0200 Subject: [PATCH] feat: add per-widget secret storage for release provider authentication Adds encrypted token storage per release widget so users can authenticate with GitHub, GitLab, Docker Hub, etc. without needing full integration setup. - New `widget_secret` table (all 3 DB drivers) keyed by (itemId, kind) - tRPC router with setSecret/deleteSecret/getConfiguredKinds (board modify access required) - Releases router injects decrypted tokens into provider requests (board view access required) - Tokens only used for repos matching widget's stored configuration (prevents proxy abuse) - Provider auth: GitHub (Octokit auth), GitLab (PRIVATE-TOKEN), Docker Hub (Bearer), GHCR (registry token), npm (Bearer), Codeberg (token header) - UI: "Provider Tokens" section in widget editor with PasswordInput per provider --- .../src/components/board/items/item-menu.tsx | 1 + packages/api/src/router/widgets/index.ts | 1 + packages/api/src/router/widgets/releases.ts | 37 ++++++- .../api/src/router/widgets/widget-secrets.ts | 63 +++++++++++ packages/db/schema/index.ts | 1 + packages/db/schema/mysql.ts | 27 +++++ packages/db/schema/postgresql.ts | 27 +++++ packages/db/schema/sqlite.ts | 27 +++++ .../request-handler/src/release-providers.ts | 80 ++++++++++---- packages/translation/src/lang/en.json | 7 ++ packages/widgets/src/_inputs/common.tsx | 1 + ...widget-multiReleasesRepositories-input.tsx | 101 ++++++++++++++++++ .../widgets/src/modals/widget-edit-modal.tsx | 2 + packages/widgets/src/releases/component.tsx | 3 +- 14 files changed, 358 insertions(+), 20 deletions(-) create mode 100644 packages/api/src/router/widgets/widget-secrets.ts diff --git a/apps/nextjs/src/components/board/items/item-menu.tsx b/apps/nextjs/src/components/board/items/item-menu.tsx index 1c0c4100a..b8a136bbb 100644 --- a/apps/nextjs/src/components/board/items/item-menu.tsx +++ b/apps/nextjs/src/components/board/items/item-menu.tsx @@ -85,6 +85,7 @@ const BoardItemMenuInner = ({ offset, item, resetErrorBoundary }: BoardItemMenuP ), integrationSupport: "supportedIntegrations" in currentDefinition, settings, + itemId: item.id, appId: item.kind === "app" ? (item.options.appId as string | undefined) : undefined, }, { diff --git a/packages/api/src/router/widgets/index.ts b/packages/api/src/router/widgets/index.ts index 80776db45..046f422fe 100644 --- a/packages/api/src/router/widgets/index.ts +++ b/packages/api/src/router/widgets/index.ts @@ -39,4 +39,5 @@ export const widgetRouter = createTRPCRouter({ vpn: lazy(() => import("./vpn").then((mod) => mod.vpnRouter)), ups: lazy(() => import("./ups").then((mod) => mod.upsRouter)), customApi: lazy(() => import("./custom-api").then((mod) => mod.customApiRouter)), + secrets: lazy(() => import("./widget-secrets").then((mod) => mod.widgetSecretsRouter)), }); diff --git a/packages/api/src/router/widgets/releases.ts b/packages/api/src/router/widgets/releases.ts index b403b87cb..4bb5cb1b8 100644 --- a/packages/api/src/router/widgets/releases.ts +++ b/packages/api/src/router/widgets/releases.ts @@ -1,10 +1,15 @@ +import SuperJSON from "superjson"; import { escapeForRegEx } from "@tiptap/react"; import { z } from "zod/v4"; +import { decryptSecret } from "@homarr/common/server"; +import { eq } from "@homarr/db"; +import { boards, items, widgetSecrets } from "@homarr/db/schema"; import { releaseProviderKinds } from "@homarr/definitions"; import { releasesRequestHandler } from "@homarr/request-handler/releases"; import { createTRPCRouter, publicProcedure } from "../../trpc"; +import { throwIfActionForbiddenAsync } from "../board/board-access"; const formatVersionFilterRegex = (versionFilter: z.infer | undefined) => { if (!versionFilter) return undefined; @@ -26,6 +31,7 @@ export const releasesRouter = createTRPCRouter({ getLatest: publicProcedure .input( z.object({ + itemId: z.string().optional(), repositories: z.array( z.object({ id: z.string(), @@ -37,9 +43,37 @@ export const releasesRouter = createTRPCRouter({ ), }), ) - .query(async ({ input }) => { + .query(async ({ ctx, input }) => { + const tokensByProvider = new Map(); + let allowedRepoIds: Set | null = null; + + if (input.itemId) { + const item = await ctx.db.query.items.findFirst({ where: eq(items.id, input.itemId) }); + if (item && item.kind === "releases") { + await throwIfActionForbiddenAsync(ctx, eq(boards.id, item.boardId), "view"); + + const options = SuperJSON.parse>(item.options); + const repos = options.repositories as Array<{ id?: string }> | undefined; + if (repos) { + allowedRepoIds = new Set(repos.map((r) => r.id).filter((id): id is string => Boolean(id))); + } + + const secrets = await ctx.db.query.widgetSecrets.findMany({ + where: eq(widgetSecrets.itemId, input.itemId), + }); + for (const secret of secrets) { + try { + tokensByProvider.set(secret.kind, decryptSecret(secret.value)); + } catch { + // Skip corrupt secrets + } + } + } + } + return await Promise.all( input.repositories.map(async (repository) => { + const useToken = allowedRepoIds === null || allowedRepoIds.has(repository.id); const response = await releasesRequestHandler .handler({ id: repository.id, @@ -47,6 +81,7 @@ export const releasesRouter = createTRPCRouter({ identifier: repository.identifier, versionRegex: formatVersionFilterRegex(repository.versionFilter), providerUrl: repository.providerUrl, + token: useToken ? tokensByProvider.get(repository.provider) : undefined, }) .getCachedOrUpdatedDataAsync({ forceUpdate: false, diff --git a/packages/api/src/router/widgets/widget-secrets.ts b/packages/api/src/router/widgets/widget-secrets.ts new file mode 100644 index 000000000..44abf84c0 --- /dev/null +++ b/packages/api/src/router/widgets/widget-secrets.ts @@ -0,0 +1,63 @@ +import { TRPCError } from "@trpc/server"; +import { z } from "zod/v4"; + +import { encryptSecret } from "@homarr/common/server"; +import { and, eq } from "@homarr/db"; +import { boards, items, widgetSecrets } from "@homarr/db/schema"; +import { releaseProviderKinds } from "@homarr/definitions"; + +import { createTRPCRouter, protectedProcedure } from "../../trpc"; +import { throwIfActionForbiddenAsync } from "../board/board-access"; + +const secretKindSchema = z.enum(releaseProviderKinds); + +const resolveItemWithBoardAccess = async ( + ctx: { db: any; session: any }, + itemId: string, +) => { + const item = await ctx.db.query.items.findFirst({ where: eq(items.id, itemId) }); + if (!item || item.kind !== "releases") throw new TRPCError({ code: "NOT_FOUND" }); + await throwIfActionForbiddenAsync(ctx, eq(boards.id, item.boardId), "modify"); + return item; +}; + +export const widgetSecretsRouter = createTRPCRouter({ + setSecret: protectedProcedure + .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))); + + await ctx.db.insert(widgetSecrets).values({ + itemId: input.itemId, + kind: input.kind, + value: encryptSecret(input.value), + updatedAt: new Date(), + }); + }), + + deleteSecret: protectedProcedure + .input(z.object({ itemId: z.string(), kind: secretKindSchema })) + .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))); + }), + + getConfiguredKinds: protectedProcedure + .input(z.object({ itemId: z.string() })) + .query(async ({ ctx, input }) => { + await resolveItemWithBoardAccess(ctx, input.itemId); + + const secrets = await ctx.db.query.widgetSecrets.findMany({ + where: eq(widgetSecrets.itemId, input.itemId), + columns: { kind: true }, + }); + return secrets.map((s: { kind: string }) => s.kind); + }), +}); diff --git a/packages/db/schema/index.ts b/packages/db/schema/index.ts index 59566631f..8c3b7f4fc 100644 --- a/packages/db/schema/index.ts +++ b/packages/db/schema/index.ts @@ -51,6 +51,7 @@ export const { cronJobConfigurations, customWidgetDefinitions, customWidgetSecrets, + widgetSecrets, } = schema; export type User = InferSelectModel; diff --git a/packages/db/schema/mysql.ts b/packages/db/schema/mysql.ts index 372a2aa9f..71af5dbd2 100644 --- a/packages/db/schema/mysql.ts +++ b/packages/db/schema/mysql.ts @@ -541,6 +541,25 @@ export const customWidgetSecrets = mysqlTable( }), ); +export const widgetSecrets = mysqlTable( + "widget_secret", + { + itemId: varchar({ length: 64 }) + .notNull() + .references(() => items.id, { onDelete: "cascade" }), + kind: varchar({ length: 64 }).notNull(), + value: text().$type<`${string}.${string}`>().notNull(), + updatedAt: timestamp() + .$onUpdateFn(() => new Date()) + .notNull(), + }, + (table) => ({ + compoundKey: primaryKey({ + columns: [table.itemId, table.kind], + }), + }), +); + export const cronJobConfigurations = mysqlTable("cron_job_configuration", { name: varchar({ length: 256 }).notNull().primaryKey(), cronExpression: varchar({ length: 32 }).notNull(), @@ -746,12 +765,20 @@ export const sectionCollapseStateRelations = relations(sectionCollapseStates, ({ export const itemRelations = relations(items, ({ one, many }) => ({ integrations: many(integrationItems), layouts: many(itemLayouts), + secrets: many(widgetSecrets), board: one(boards, { fields: [items.boardId], references: [boards.id], }), })); +export const widgetSecretRelations = relations(widgetSecrets, ({ one }) => ({ + item: one(items, { + fields: [widgetSecrets.itemId], + references: [items.id], + }), +})); + export const integrationItemRelations = relations(integrationItems, ({ one }) => ({ integration: one(integrations, { fields: [integrationItems.integrationId], diff --git a/packages/db/schema/postgresql.ts b/packages/db/schema/postgresql.ts index 0e104f7f4..092ecc64b 100644 --- a/packages/db/schema/postgresql.ts +++ b/packages/db/schema/postgresql.ts @@ -540,6 +540,25 @@ export const customWidgetSecrets = pgTable( }), ); +export const widgetSecrets = pgTable( + "widget_secret", + { + itemId: varchar({ length: 64 }) + .notNull() + .references(() => items.id, { onDelete: "cascade" }), + kind: varchar({ length: 64 }).notNull(), + value: text().$type<`${string}.${string}`>().notNull(), + updatedAt: timestamp() + .$onUpdateFn(() => new Date()) + .notNull(), + }, + (table) => ({ + compoundKey: primaryKey({ + columns: [table.itemId, table.kind], + }), + }), +); + export const cronJobConfigurations = pgTable("cron_job_configuration", { name: varchar({ length: 256 }).notNull().primaryKey(), cronExpression: varchar({ length: 32 }).notNull(), @@ -745,12 +764,20 @@ export const sectionCollapseStateRelations = relations(sectionCollapseStates, ({ export const itemRelations = relations(items, ({ one, many }) => ({ integrations: many(integrationItems), layouts: many(itemLayouts), + secrets: many(widgetSecrets), board: one(boards, { fields: [items.boardId], references: [boards.id], }), })); +export const widgetSecretRelations = relations(widgetSecrets, ({ one }) => ({ + item: one(items, { + fields: [widgetSecrets.itemId], + references: [items.id], + }), +})); + export const integrationItemRelations = relations(integrationItems, ({ one }) => ({ integration: one(integrations, { fields: [integrationItems.integrationId], diff --git a/packages/db/schema/sqlite.ts b/packages/db/schema/sqlite.ts index 309d58507..b791609d4 100644 --- a/packages/db/schema/sqlite.ts +++ b/packages/db/schema/sqlite.ts @@ -530,6 +530,25 @@ export const customWidgetSecrets = sqliteTable( }), ); +export const widgetSecrets = sqliteTable( + "widget_secret", + { + itemId: text() + .notNull() + .references(() => items.id, { onDelete: "cascade" }), + kind: text().notNull(), + value: text().$type<`${string}.${string}`>().notNull(), + updatedAt: int({ mode: "timestamp" }) + .$onUpdateFn(() => new Date()) + .notNull(), + }, + (table) => ({ + compoundKey: primaryKey({ + columns: [table.itemId, table.kind], + }), + }), +); + export const cronJobConfigurations = sqliteTable("cron_job_configuration", { name: text().notNull().primaryKey(), cronExpression: text().notNull(), @@ -735,12 +754,20 @@ export const sectionCollapseStateRelations = relations(sectionCollapseStates, ({ export const itemRelations = relations(items, ({ one, many }) => ({ integrations: many(integrationItems), layouts: many(itemLayouts), + secrets: many(widgetSecrets), board: one(boards, { fields: [items.boardId], references: [boards.id], }), })); +export const widgetSecretRelations = relations(widgetSecrets, ({ one }) => ({ + item: one(items, { + fields: [widgetSecrets.itemId], + references: [items.id], + }), +})); + export const integrationItemRelations = relations(integrationItems, ({ one }) => ({ integration: one(integrations, { fields: [integrationItems.integrationId], diff --git a/packages/request-handler/src/release-providers.ts b/packages/request-handler/src/release-providers.ts index 31c8a29fe..bdc0d6e9e 100644 --- a/packages/request-handler/src/release-providers.ts +++ b/packages/request-handler/src/release-providers.ts @@ -14,6 +14,7 @@ export interface ReleasesRepositoryRequest extends Record { identifier: string; versionRegex?: string; providerUrl?: string; + token?: string; } export interface DetailsProviderResponse { @@ -61,17 +62,17 @@ export const getLatestMatchingReleaseAsync = async (input: ReleasesRepositoryReq switch (input.provider) { case "github": - return await getGithubReleaseAsync(baseUrl, identifier, input.versionRegex); + return await getGithubReleaseAsync(baseUrl, identifier, input.versionRegex, input.token); case "gitHubContainerRegistry": - return await getGitHubContainerRegistryReleaseAsync(baseUrl, identifier, input.versionRegex); + return await getGitHubContainerRegistryReleaseAsync(baseUrl, identifier, input.versionRegex, input.token); case "dockerHub": - return await getDockerHubReleaseAsync(baseUrl, identifier, input.versionRegex); + return await getDockerHubReleaseAsync(baseUrl, identifier, input.versionRegex, input.token); case "gitlab": - return await getGitlabReleaseAsync(baseUrl, identifier, input.versionRegex); + return await getGitlabReleaseAsync(baseUrl, identifier, input.versionRegex, input.token); case "npm": - return await getNpmReleaseAsync(baseUrl, identifier, input.versionRegex); + return await getNpmReleaseAsync(baseUrl, identifier, input.versionRegex, input.token); case "codeberg": - return await getCodebergReleaseAsync(baseUrl, identifier, input.versionRegex); + return await getCodebergReleaseAsync(baseUrl, identifier, input.versionRegex, input.token); case "linuxServerIO": return await getLinuxServerIOReleaseAsync(baseUrl, identifier); case "quay": @@ -96,9 +97,10 @@ const removeTrailingSlash = (url: string) => url.replace(/\/$/, ""); const buildUrl = (baseUrl: string, path: `/${string}`) => new URL(`${baseUrl}${path}`); -const getGithubApi = (baseUrl: string, userAgent: string) => +const getGithubApi = (baseUrl: string, userAgent: string, token?: string) => new Octokit({ baseUrl, + auth: token, request: { fetch: fetchWithTrustedCertificatesAsync }, throttle: { enabled: false }, userAgent, @@ -108,11 +110,12 @@ const getGithubReleaseAsync = async ( baseUrl: string, identifier: string, versionRegex?: string, + token?: string, ): Promise => { const parsed = parseOwnerName(identifier); if (!parsed) return { success: false, error: { code: "invalidIdentifier" } }; - const api = getGithubApi(baseUrl, "Homarr-Lab/Homarr:GithubReleaseProvider"); + const api = getGithubApi(baseUrl, "Homarr-Lab/Homarr:GithubReleaseProvider", token); try { const releasesResponse = await api.rest.repos.listReleases({ owner: parsed.owner, repo: parsed.name }); if (releasesResponse.data.length === 0) return { success: false, error: { code: "noMatchingVersion" } }; @@ -163,6 +166,7 @@ const getGitHubContainerRegistryReleaseAsync = async ( baseUrl: string, identifier: string, versionRegex?: string, + token?: string, ): Promise => { const parsed = parseOwnerPackageName(identifier); if (!parsed) return { success: false, error: { code: "invalidIdentifier" } }; @@ -170,8 +174,8 @@ const getGitHubContainerRegistryReleaseAsync = async ( try { const repositoryName = `${parsed.owner}/${parsed.name}`; const registryUrl = new URL(baseUrl); - const token = await getGitHubContainerRegistryTokenAsync(registryUrl, repositoryName); - const tags = await getGitHubContainerRegistryTagsAsync(registryUrl, repositoryName, token); + const registryToken = token ?? await getGitHubContainerRegistryTokenAsync(registryUrl, repositoryName); + const tags = await getGitHubContainerRegistryTagsAsync(registryUrl, repositoryName, registryToken); const matchingTags = versionRegex ? tags.filter((tag) => new RegExp(versionRegex).test(tag)) : tags; const tagsToCheck = versionRegex ? matchingTags.slice(-100) @@ -183,7 +187,7 @@ const getGitHubContainerRegistryReleaseAsync = async ( const releases = await Promise.all( tagsToCheck.map(async (tag) => ({ latestRelease: tag, - latestReleaseAt: await getGitHubContainerRegistryTagCreatedAtAsync(registryUrl, repositoryName, tag, token), + latestReleaseAt: await getGitHubContainerRegistryTagCreatedAtAsync(registryUrl, repositoryName, tag, registryToken), releaseUrl: `https://github.com/${repositoryName}/pkgs/container/${encodeURIComponent(parsed.name)}`, })), ); @@ -323,6 +327,7 @@ const getDockerHubReleaseAsync = async ( baseUrl: string, identifier: string, versionRegex?: string, + token?: string, ): Promise => { const parsed = identifier.includes("/") ? parseOwnerName(identifier) : { owner: "", name: identifier }; if (!parsed) return { success: false, error: { code: "invalidIdentifier" } }; @@ -331,9 +336,13 @@ const getDockerHubReleaseAsync = async ( ? `/v2/namespaces/${encodeURIComponent(parsed.owner)}/repositories/${encodeURIComponent(parsed.name)}` : `/v2/repositories/library/${encodeURIComponent(parsed.name)}`; + const headers: Record = {}; + if (token) headers.Authorization = `Bearer ${token}`; + for (let page = 0; page <= 5; page++) { const response = await fetchWithTrustedCertificatesAsync( buildUrl(baseUrl, `${relativeUrl}/tags?page_size=100&page=${page}` as `/${string}`), + headers.Authorization ? { headers } : undefined, ); if (!response.ok) return { success: false, error: { code: "unexpected", message: response.statusText } }; @@ -344,7 +353,7 @@ const getDockerHubReleaseAsync = async ( const latestRelease = getLatestRelease(result.data.results, versionRegex); if (!latestRelease) continue; - const details = await getDockerHubDetailsAsync(baseUrl, relativeUrl as `/${string}`); + const details = await getDockerHubDetailsAsync(baseUrl, relativeUrl as `/${string}`, token); return { success: true, data: { ...details, ...latestRelease } }; } @@ -354,8 +363,15 @@ const getDockerHubReleaseAsync = async ( const getDockerHubDetailsAsync = async ( baseUrl: string, relativeUrl: `/${string}`, + token?: string, ): Promise => { - const response = await fetchWithTrustedCertificatesAsync(buildUrl(baseUrl, `${relativeUrl}/` as `/${string}`)); + const headers: Record = {}; + if (token) headers.Authorization = `Bearer ${token}`; + + const response = await fetchWithTrustedCertificatesAsync( + buildUrl(baseUrl, `${relativeUrl}/` as `/${string}`), + headers.Authorization ? { headers } : undefined, + ); if (!response.ok) return {}; const result = dockerHubDetailsSchema.safeParse(await response.json()); @@ -394,10 +410,15 @@ const getGitlabReleaseAsync = async ( baseUrl: string, identifier: string, versionRegex?: string, + token?: string, ): Promise => { const encodedIdentifier = encodeURIComponent(identifier); + const headers: Record = {}; + if (token) headers["PRIVATE-TOKEN"] = token; + const response = await fetchWithTrustedCertificatesAsync( buildUrl(baseUrl, `/api/v4/projects/${encodedIdentifier}/releases?per_page=100`), + headers["PRIVATE-TOKEN"] ? { headers } : undefined, ); if (!response.ok) return { success: false, error: { code: "unexpected", message: response.statusText } }; @@ -420,12 +441,18 @@ const getGitlabReleaseAsync = async ( const latestRelease = getLatestRelease(releases, versionRegex); if (!latestRelease) return { success: false, error: { code: "noMatchingVersion" } }; - const details = await getGitlabDetailsAsync(baseUrl, encodedIdentifier); + const details = await getGitlabDetailsAsync(baseUrl, encodedIdentifier, token); return { success: true, data: { ...details, ...latestRelease } }; }; -const getGitlabDetailsAsync = async (baseUrl: string, encodedIdentifier: string): Promise => { - const response = await fetchWithTrustedCertificatesAsync(buildUrl(baseUrl, `/api/v4/projects/${encodedIdentifier}`)); +const getGitlabDetailsAsync = async (baseUrl: string, encodedIdentifier: string, token?: string): Promise => { + const headers: Record = {}; + if (token) headers["PRIVATE-TOKEN"] = token; + + const response = await fetchWithTrustedCertificatesAsync( + buildUrl(baseUrl, `/api/v4/projects/${encodedIdentifier}`), + headers["PRIVATE-TOKEN"] ? { headers } : undefined, + ); if (!response.ok) return {}; const result = gitlabDetailsSchema.safeParse(await response.json()); @@ -464,10 +491,17 @@ const getNpmReleaseAsync = async ( baseUrl: string, identifier: string, versionRegex?: string, + token?: string, ): Promise => { if (!identifier) return { success: false, error: { code: "invalidIdentifier" } }; - const response = await fetchWithTrustedCertificatesAsync(buildUrl(baseUrl, `/${encodeURIComponent(identifier)}`)); + const headers: Record = {}; + if (token) headers.Authorization = `Bearer ${token}`; + + const response = await fetchWithTrustedCertificatesAsync( + buildUrl(baseUrl, `/${encodeURIComponent(identifier)}`), + headers.Authorization ? { headers } : undefined, + ); if (!response.ok) return { success: false, error: { code: "unexpected", message: response.statusText } }; const result = npmReleasesSchema.safeParse(await response.json()); @@ -509,12 +543,17 @@ const getCodebergReleaseAsync = async ( baseUrl: string, identifier: string, versionRegex?: string, + token?: string, ): Promise => { const parsed = parseOwnerName(identifier); if (!parsed) return { success: false, error: { code: "invalidIdentifier" } }; + const headers: Record = {}; + if (token) headers.Authorization = `token ${token}`; + const response = await fetchWithTrustedCertificatesAsync( buildUrl(baseUrl, `/api/v1/repos/${encodeURIComponent(parsed.owner)}/${encodeURIComponent(parsed.name)}/releases`), + headers.Authorization ? { headers } : undefined, ); if (!response.ok) return { success: false, error: { code: "unexpected", message: response.statusText } }; @@ -531,7 +570,7 @@ const getCodebergReleaseAsync = async ( const latestRelease = getLatestRelease(releases, versionRegex); if (!latestRelease) return { success: false, error: { code: "noMatchingVersion" } }; - const details = await getCodebergDetailsAsync(baseUrl, parsed.owner, parsed.name); + const details = await getCodebergDetailsAsync(baseUrl, parsed.owner, parsed.name, token); return { success: true, data: { ...details, ...latestRelease } }; }; @@ -539,9 +578,14 @@ const getCodebergDetailsAsync = async ( baseUrl: string, owner: string, name: string, + token?: string, ): Promise => { + const headers: Record = {}; + if (token) headers.Authorization = `token ${token}`; + const response = await fetchWithTrustedCertificatesAsync( buildUrl(baseUrl, `/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}`), + headers.Authorization ? { headers } : undefined, ); if (!response.ok) return {}; diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index 4006150cb..f12587888 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -3201,6 +3201,13 @@ "noProvider": { "label": "No Provider", "tooltip": "The provider could not be parsed, please manually set it after importing the images" + }, + "tokens": { + "label": "Provider Tokens", + "token": "Token", + "configured": "Token configured", + "notConfigured": "No token set", + "save": "Save" } } }, diff --git a/packages/widgets/src/_inputs/common.tsx b/packages/widgets/src/_inputs/common.tsx index 5c7769eb1..c7be65262 100644 --- a/packages/widgets/src/_inputs/common.tsx +++ b/packages/widgets/src/_inputs/common.tsx @@ -8,6 +8,7 @@ export interface CommonWidgetInputProps { property: string; options: Omit, "defaultValue" | "type">; initialOptions: Record; + itemId?: string; } type UseWidgetInputTranslationReturnType = (key: "label" | "description") => string; diff --git a/packages/widgets/src/_inputs/widget-multiReleasesRepositories-input.tsx b/packages/widgets/src/_inputs/widget-multiReleasesRepositories-input.tsx index bf310c4c1..8b591f02f 100644 --- a/packages/widgets/src/_inputs/widget-multiReleasesRepositories-input.tsx +++ b/packages/widgets/src/_inputs/widget-multiReleasesRepositories-input.tsx @@ -12,6 +12,7 @@ import { Group, Image, Loader, + PasswordInput, Select, Stack, Text, @@ -27,6 +28,7 @@ import { IconCopy, IconCopyCheckFilled, IconEdit, + IconKey, IconPackageImport, IconPlus, IconTrash, @@ -64,6 +66,7 @@ interface FormValidation { export const WidgetMultiReleasesRepositoriesInput = ({ property, kind, + itemId, }: CommonWidgetInputProps<"multiReleasesRepositories">) => { const t = useWidgetInputTranslation(kind, property); const tRepository = useScopedI18n("widget.releases.option.repositories"); @@ -249,10 +252,108 @@ export const WidgetMultiReleasesRepositoriesInput = ({ ); })} + {itemId && } ); }; +const providersWithAuth: ReleaseProviderKind[] = ["github", "gitHubContainerRegistry", "dockerHub", "gitlab", "npm", "codeberg"]; + +const ProviderTokensSection = ({ + itemId, + repositories, +}: { + itemId: string; + repositories: ReleasesRepository[]; +}) => { + const tRepository = useScopedI18n("widget.releases.option.repositories"); + const { data: configuredKinds = [], refetch } = clientApi.widget.secrets.getConfiguredKinds.useQuery({ itemId }); + 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 = usedProviders.filter((p) => providersWithAuth.includes(p)); + if (authProviders.length === 0) return null; + + return ( + <> + + + {authProviders.map((provider) => ( + setSecret.mutate({ itemId, kind: provider, value })} + onDelete={() => deleteSecret.mutate({ itemId, kind: provider })} + /> + ))} + + + ); +}; + +const ProviderTokenInput = ({ + provider, + hasToken, + onSave, + onDelete, +}: { + provider: ReleaseProviderKind; + hasToken: boolean; + onSave: (value: string) => void; + onDelete: () => void; +}) => { + const tRepository = useScopedI18n("widget.releases.option.repositories"); + const [value, setValue] = useState(""); + const [editing, setEditing] = useState(false); + + const handleSave = () => { + if (value.trim()) { + onSave(value.trim()); + setValue(""); + setEditing(false); + } + }; + + const handleDelete = () => { + onDelete(); + setValue(""); + setEditing(false); + }; + + return ( + + { + setValue(e.currentTarget.value); + setEditing(true); + }} + style={{ flex: 1 }} + size="xs" + leftSection={} + /> + {editing && value.trim() && ( + + )} + {hasToken && !editing && ( + + + + )} + + ); +}; + const formatVersionFilterRegex = (versionFilter: ReleasesVersionFilter | undefined) => { if (!versionFilter) return undefined; diff --git a/packages/widgets/src/modals/widget-edit-modal.tsx b/packages/widgets/src/modals/widget-edit-modal.tsx index be0d3a178..d1713298c 100644 --- a/packages/widgets/src/modals/widget-edit-modal.tsx +++ b/packages/widgets/src/modals/widget-edit-modal.tsx @@ -38,6 +38,7 @@ interface ModalProps { integrationData: IntegrationSelectOption[]; integrationSupport: boolean; settings: SettingsContextProps; + itemId?: string; appId?: string; } @@ -146,6 +147,7 @@ export const WidgetEditModal = createModal>(({ actions, i property={key} options={value as never} initialOptions={innerProps.value.options} + itemId={innerProps.itemId} /> ); })} diff --git a/packages/widgets/src/releases/component.tsx b/packages/widgets/src/releases/component.tsx index 9be7f8791..6a12d4c0b 100644 --- a/packages/widgets/src/releases/component.tsx +++ b/packages/widgets/src/releases/component.tsx @@ -36,7 +36,7 @@ const formatRelativeDate = (value: string): string => { return isMonths ? value.toUpperCase() : isOtherUnits ? value.toLowerCase() : value; }; -export default function ReleasesWidget({ options }: WidgetComponentProps<"releases">) { +export default function ReleasesWidget({ options, itemId }: WidgetComponentProps<"releases">) { const t = useScopedI18n("widget.releases"); const now = useNow(); const formatter = useFormatter(); @@ -85,6 +85,7 @@ export default function ReleasesWidget({ options }: WidgetComponentProps<"releas const queryResults = clientApi.useQueries((trpc) => batchedRepositories.flatMap(({ provider, repositories }) => trpc.widget.releases.getLatest({ + itemId, repositories: repositories.map((repository) => ({ id: repository.id, provider,