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:
Thomas Camlong committed 2026-06-27 10:57:13 +02:00
1 parent d8a7477039
commit 77884a3179
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,
},
{
+1
View File
@@ -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)),
});
+36 -1
View File
@@ -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);
}),
});
+1
View File
@@ -51,6 +51,7 @@ export const {
cronJobConfigurations,
customWidgetDefinitions,
customWidgetSecrets,
widgetSecrets,
} = schema;
export type User = InferSelectModel<typeof schema.users>;
+27
View File
@@ -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],
+27
View File
@@ -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],
+27
View File
@@ -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 {};
+7
View File
@@ -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"
}
}
},
+1
View File
@@ -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}
/>
);
})}
+2 -1
View File
@@ -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,