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
This commit is contained in:
14 files changed
+358
-20
No files matched your search
@@ -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,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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)),
|
||||
});
|
||||
@@ -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<typeof releaseVersionFilterSchema> | 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<string, string>();
|
||||
let allowedRepoIds: Set<string> | 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<Record<string, unknown>>(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,
|
||||
|
||||
@@ -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);
|
||||
}),
|
||||
});
|
||||
@@ -51,6 +51,7 @@ export const {
|
||||
cronJobConfigurations,
|
||||
customWidgetDefinitions,
|
||||
customWidgetSecrets,
|
||||
widgetSecrets,
|
||||
} = schema;
|
||||
|
||||
export type User = InferSelectModel<typeof schema.users>;
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -14,6 +14,7 @@ export interface ReleasesRepositoryRequest extends Record<string, unknown> {
|
||||
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<ReleaseResponse> => {
|
||||
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<ReleaseResponse> => {
|
||||
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<ReleaseResponse> => {
|
||||
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<string, string> = {};
|
||||
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<DetailsProviderResponse> => {
|
||||
const response = await fetchWithTrustedCertificatesAsync(buildUrl(baseUrl, `${relativeUrl}/` as `/${string}`));
|
||||
const headers: Record<string, string> = {};
|
||||
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<ReleaseResponse> => {
|
||||
const encodedIdentifier = encodeURIComponent(identifier);
|
||||
const headers: Record<string, string> = {};
|
||||
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<DetailsProviderResponse> => {
|
||||
const response = await fetchWithTrustedCertificatesAsync(buildUrl(baseUrl, `/api/v4/projects/${encodedIdentifier}`));
|
||||
const getGitlabDetailsAsync = async (baseUrl: string, encodedIdentifier: string, token?: string): Promise<DetailsProviderResponse> => {
|
||||
const headers: Record<string, string> = {};
|
||||
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<ReleaseResponse> => {
|
||||
if (!identifier) return { success: false, error: { code: "invalidIdentifier" } };
|
||||
|
||||
const response = await fetchWithTrustedCertificatesAsync(buildUrl(baseUrl, `/${encodeURIComponent(identifier)}`));
|
||||
const headers: Record<string, string> = {};
|
||||
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<ReleaseResponse> => {
|
||||
const parsed = parseOwnerName(identifier);
|
||||
if (!parsed) return { success: false, error: { code: "invalidIdentifier" } };
|
||||
|
||||
const headers: Record<string, string> = {};
|
||||
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<DetailsProviderResponse> => {
|
||||
const headers: Record<string, string> = {};
|
||||
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 {};
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -8,6 +8,7 @@ export interface CommonWidgetInputProps<TKey extends WidgetOptionType> {
|
||||
property: string;
|
||||
options: Omit<WidgetOptionOfType<TKey>, "defaultValue" | "type">;
|
||||
initialOptions: Record<string, unknown>;
|
||||
itemId?: string;
|
||||
}
|
||||
|
||||
type UseWidgetInputTranslationReturnType = (key: "label" | "description") => string;
|
||||
|
||||
@@ -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 = ({
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
{itemId && <ProviderTokensSection itemId={itemId} repositories={repositories} />}
|
||||
</Fieldset>
|
||||
);
|
||||
};
|
||||
|
||||
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 (
|
||||
<>
|
||||
<Divider my="sm" label={tRepository("tokens.label")} labelPosition="center" />
|
||||
<Stack gap="xs">
|
||||
{authProviders.map((provider) => (
|
||||
<ProviderTokenInput
|
||||
key={provider}
|
||||
provider={provider}
|
||||
hasToken={configuredKinds.includes(provider)}
|
||||
onSave={(value) => setSecret.mutate({ itemId, kind: provider, value })}
|
||||
onDelete={() => deleteSecret.mutate({ itemId, kind: provider })}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
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 (
|
||||
<Group gap="xs" align="end">
|
||||
<PasswordInput
|
||||
label={`${getReleaseProviderName(provider)} ${tRepository("tokens.token")}`}
|
||||
placeholder={hasToken ? tRepository("tokens.configured") : tRepository("tokens.notConfigured")}
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
setValue(e.currentTarget.value);
|
||||
setEditing(true);
|
||||
}}
|
||||
style={{ flex: 1 }}
|
||||
size="xs"
|
||||
leftSection={<IconKey size={14} />}
|
||||
/>
|
||||
{editing && value.trim() && (
|
||||
<Button size="xs" onClick={handleSave}>
|
||||
{tRepository("tokens.save")}
|
||||
</Button>
|
||||
)}
|
||||
{hasToken && !editing && (
|
||||
<ActionIcon variant="light" color="red" size="sm" onClick={handleDelete}>
|
||||
<IconTrash size={14} />
|
||||
</ActionIcon>
|
||||
)}
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
|
||||
const formatVersionFilterRegex = (versionFilter: ReleasesVersionFilter | undefined) => {
|
||||
if (!versionFilter) return undefined;
|
||||
|
||||
|
||||
@@ -38,6 +38,7 @@ interface ModalProps<TSort extends WidgetKind> {
|
||||
integrationData: IntegrationSelectOption[];
|
||||
integrationSupport: boolean;
|
||||
settings: SettingsContextProps;
|
||||
itemId?: string;
|
||||
appId?: string;
|
||||
}
|
||||
|
||||
@@ -146,6 +147,7 @@ export const WidgetEditModal = createModal<ModalProps<WidgetKind>>(({ actions, i
|
||||
property={key}
|
||||
options={value as never}
|
||||
initialOptions={innerProps.value.options}
|
||||
itemId={innerProps.itemId}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user