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

@@ -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;