feat(custom-widgets): configure missing credentials

This commit is contained in:
Thomas Camlong committed 2026-08-17 15:47:42 +02:00
1 parent 6d284adb98
commit 38fc5dd582
4 files changed
+245 -66

No files matched your search

@@ -1,12 +1,14 @@
"use client";
import { Avatar, Badge, Button, Group, Text, Tooltip } from "@mantine/core";
import { Avatar, Badge, Button, Group, Text, Tooltip, UnstyledButton } from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import { IconAlertTriangle, IconApi, IconPencil, IconSparkles } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { useScopedI18n } from "@homarr/translation/client";
import { Link } from "@homarr/ui";
import { CustomWidgetSourceSetupDialog } from "~/components/custom-widgets/custom-widget-source-setup-dialog";
import { ManageCollectionItem, ManageCollectionList } from "~/components/manage/manage-collection";
import { NoResults } from "~/components/no-results";
import { CustomWidgetRowActions } from "./_custom-widget-actions";
@@ -41,75 +43,92 @@ type WidgetDef = RouterOutputs["customWidget"]["list"][number];
function CustomWidgetRow({ widget }: { widget: WidgetDef }) {
const t = useScopedI18n("customWidget");
const [sourceSetupOpened, sourceSetupControls] = useDisclosure(false);
const origins = [...new Set(widget.sources.map((source) => source.origin))];
return (
<ManageCollectionItem
leading={
<Avatar
size={40}
radius="sm"
src={widget.iconUrl}
color={widget.valid ? "pink" : "red"}
styles={{ image: { objectFit: "contain" } }}
alt=""
>
<IconApi size={20} stroke={1.5} />
</Avatar>
}
title={
<Text component="span" fw={600} lineClamp={1} c={widget.enabled || !widget.valid ? undefined : "dimmed"}>
{widget.name}
</Text>
}
badges={<CustomWidgetStatusBadges widget={widget} />}
description={
<Text size="sm" c="dimmed" lineClamp={2}>
{widget.description || t("page.list.noDescription")}
</Text>
}
metadata={
<Group gap={6} wrap="wrap">
<Text size="xs" c="dimmed">
{t("page.list.requestCount", { count: widget.requestCount })}
<>
<ManageCollectionItem
leading={
<Avatar
size={40}
radius="sm"
src={widget.iconUrl}
color={widget.valid ? "pink" : "red"}
styles={{ image: { objectFit: "contain" } }}
alt=""
>
<IconApi size={20} stroke={1.5} />
</Avatar>
}
title={
<Text component="span" fw={600} lineClamp={1} c={widget.enabled || !widget.valid ? undefined : "dimmed"}>
{widget.name}
</Text>
{origins.map((origin) => (
<Text key={origin} size="xs" c="dimmed" ff="monospace" style={{ wordBreak: "break-all" }}>
· {origin}
}
badges={<CustomWidgetStatusBadges widget={widget} onConfigureSources={sourceSetupControls.open} />}
description={
<Text size="sm" c="dimmed" lineClamp={2}>
{widget.description || t("page.list.noDescription")}
</Text>
}
metadata={
<Group gap={6} wrap="wrap">
<Text size="xs" c="dimmed">
{t("page.list.requestCount", { count: widget.requestCount })}
</Text>
))}
</Group>
}
actions={
<Group gap="xs" wrap="nowrap">
{widget.valid && (
<Button
component={Link}
href={`/manage/custom-widgets/edit/${widget.id}`}
variant="default"
size="sm"
leftSection={<IconPencil size={16} stroke={1.5} />}
>
{t("action.edit")}
</Button>
)}
<CustomWidgetRowActions
widget={{
id: widget.id,
name: widget.name,
enabled: widget.enabled,
valid: widget.valid,
migrationRequired: widget.migrationRequired,
}}
/>
</Group>
}
/>
{origins.map((origin) => (
<Text key={origin} size="xs" c="dimmed" ff="monospace" style={{ wordBreak: "break-all" }}>
· {origin}
</Text>
))}
</Group>
}
actions={
<Group gap="xs" wrap="nowrap">
{widget.valid && (
<Button
component={Link}
href={`/manage/custom-widgets/edit/${widget.id}`}
variant="default"
size="sm"
leftSection={<IconPencil size={16} stroke={1.5} />}
>
{t("action.edit")}
</Button>
)}
<CustomWidgetRowActions
widget={{
id: widget.id,
name: widget.name,
enabled: widget.enabled,
valid: widget.valid,
migrationRequired: widget.migrationRequired,
}}
/>
</Group>
}
actionsAlignment="center"
/>
{widget.valid && (
<CustomWidgetSourceSetupDialog
definitionId={widget.id}
opened={sourceSetupOpened}
onClose={sourceSetupControls.close}
/>
)}
</>
);
}
/** Everything that decides whether this widget can actually run right now. */
function CustomWidgetStatusBadges({ widget }: { widget: WidgetDef }) {
function CustomWidgetStatusBadges({
widget,
onConfigureSources,
}: {
widget: WidgetDef;
onConfigureSources(): void;
}) {
const t = useScopedI18n("customWidget");
if (widget.migrationRequired) {
@@ -157,9 +176,17 @@ function CustomWidgetStatusBadges({ widget }: { widget: WidgetDef }) {
multiline
maw={320}
>
<Badge color="yellow" size="sm" variant="light" leftSection={<IconAlertTriangle size={12} />}>
{t("page.list.missingCredentials", { count: widget.missingSecrets.length })}
</Badge>
<UnstyledButton onClick={onConfigureSources} aria-label={t("action.configureSources")}>
<Badge
color="yellow"
size="sm"
variant="light"
leftSection={<IconAlertTriangle size={12} />}
style={{ cursor: "pointer" }}
>
{t("page.list.missingCredentials", { count: widget.missingSecrets.length })}
</Badge>
</UnstyledButton>
</Tooltip>
)}
</Group>
@@ -0,0 +1,140 @@
"use client";
import { useRouter } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import { Button, Center, Group, Loader, Modal, Stack, Text } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { getCustomWidgetSourceSetups } from "@homarr/custom-widgets/core";
import type { CustomWidgetSecretKind } from "@homarr/custom-widgets/core";
import {
createCustomWidgetSourceSetupValues,
CustomWidgetSourceSetupPanel,
isCustomWidgetSourceSetupReady,
} from "@homarr/custom-widgets/workbench";
import type {
CustomWidgetSourceSetupMessages,
CustomWidgetSourceSetupValue,
} from "@homarr/custom-widgets/workbench";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
interface CustomWidgetSourceSetupDialogProps {
definitionId: string;
opened: boolean;
onClose(): void;
}
export function CustomWidgetSourceSetupDialog({
definitionId,
opened,
onClose,
}: CustomWidgetSourceSetupDialogProps) {
const t = useScopedI18n("customWidget");
const router = useRouter();
const utils = clientApi.useUtils();
const definitionQuery = clientApi.customWidget.get.useQuery({ id: definitionId }, { enabled: opened });
const configureMutation = clientApi.customWidget.sourceConfigure.useMutation();
const [saving, setSaving] = useState(false);
const [values, setValues] = useState<Record<string, CustomWidgetSourceSetupValue>>({});
const setups = useMemo(
() => getCustomWidgetSourceSetups(definitionQuery.data?.sources ?? {}, definitionQuery.data?.secrets ?? []),
[definitionQuery.data],
);
useEffect(() => {
if (opened && definitionQuery.data) setValues(createCustomWidgetSourceSetupValues(setups));
}, [definitionQuery.data, opened, setups]);
const messages: CustomWidgetSourceSetupMessages = {
title: t("importReview.sourceSetup.title"),
description: t("sourceSetupDialog.description"),
suggestedUrl: t("importReview.sourceSetup.suggestedUrl"),
baseUrl: t("workbench.sources.baseUrl"),
networkScope: t("workbench.sources.networkScope"),
authentication: t("workbench.sources.authentication"),
confirmUrl: t("importReview.sourceSetup.confirmUrl"),
ready: t("importReview.sourceSetup.ready"),
needsUrl: t("importReview.sourceSetup.needsUrl"),
credentialsMissing: t("importReview.sourceSetup.credentialsMissing"),
credentialsOptional: t("sourceSetupDialog.credentialsRequired"),
configured: t("workbench.sources.configured"),
secret: (kind) => t(`importReview.credentials.field.${kind}`),
urlError: (issue) => t(`workbench.sources.baseUrlError.${issue}`),
};
const save = async () => {
if (!isCustomWidgetSourceSetupReady(setups, values) || saving) return;
setSaving(true);
try {
for (const setup of setups) {
const value = values[setup.sourceId];
if (!value) continue;
const secrets = Object.entries(value.secrets).flatMap(([kind, secret]) =>
secret?.trim() ? [{ sourceId: setup.sourceId, kind: kind as CustomWidgetSecretKind, value: secret }] : [],
);
await configureMutation.mutateAsync({
definitionId,
sourceId: setup.sourceId,
baseUrl: value.baseUrl,
networkScope: value.networkScope,
secrets,
});
}
showSuccessNotification({
title: t("action.configureSources"),
message: t("notification.sourcesConfigured"),
});
await utils.customWidget.get.invalidate({ id: definitionId });
await utils.customWidget.list.invalidate();
await utils.customWidget.available.invalidate();
await utils.widget.customApi.getData.invalidate();
await revalidatePathActionAsync("/manage/custom-widgets");
router.refresh();
onClose();
} catch (error) {
showErrorNotification({
title: t("action.configureSources"),
message: error instanceof Error ? error.message : t("notification.sourceConfigurationError"),
});
} finally {
setSaving(false);
}
};
return (
<Modal opened={opened} onClose={onClose} title={t("sourceSetupDialog.title")} size="lg">
{definitionQuery.isPending ? (
<Center py="xl">
<Loader size="sm" />
</Center>
) : definitionQuery.error ? (
<Text c="red" size="sm">
{definitionQuery.error.message}
</Text>
) : (
<Stack gap="md">
<CustomWidgetSourceSetupPanel
setups={setups}
values={values}
messages={messages}
onChange={(sourceId, value) => setValues((current) => ({ ...current, [sourceId]: value }))}
/>
<Group justify="flex-end">
<Button variant="default" onClick={onClose} disabled={saving}>
{t("sourceSetupDialog.cancel")}
</Button>
<Button
onClick={() => void save()}
loading={saving}
disabled={!isCustomWidgetSourceSetupReady(setups, values)}
>
{t("sourceSetupDialog.save")}
</Button>
</Group>
</Stack>
)}
</Modal>
);
}
@@ -37,6 +37,7 @@ export interface ManageCollectionItemProps {
metadata?: ReactNode;
badges?: ReactNode;
actions?: ReactNode;
actionsAlignment?: "flex-start" | "center";
}
export const ManageCollectionItem = ({
@@ -46,6 +47,7 @@ export const ManageCollectionItem = ({
metadata,
badges,
actions,
actionsAlignment = "flex-start",
}: ManageCollectionItemProps) => (
<Paper component="li" p="sm" withBorder radius="md">
<Group justify="space-between" align="flex-start" wrap="nowrap" gap="sm">
@@ -59,7 +61,7 @@ export const ManageCollectionItem = ({
{metadata}
</Stack>
{actions && (
<Group gap={4} wrap="nowrap" style={{ flexShrink: 0 }}>
<Group gap={4} wrap="nowrap" style={{ flexShrink: 0, alignSelf: actionsAlignment }}>
{actions}
</Group>
)}