feat: config portability with generic entity resolver framework

Full configuration export/import system for Homarr with excellent DX:
- Export/import boards, apps, integrations (with re-encrypted secrets),
  users (with bcrypt hashes), groups (with permissions and memberships),
  search engines, and server settings
- Homepage services.yaml import with widget-type mapping
- Generic resolveEntities() utility eliminates per-entity boilerplate
- Declarative entity-fields.ts: adding a new user preference requires
  only one line change (no export/import/schema edits needed)
- User schema uses .catchall(z.unknown()) for forward-compatible prefs
- groupByKey() utility replaces manual Map-building
- CONFIG_ENTITY_KEYS as single source of truth for entity metadata
- Reuse existing entities (apps by name+icon+href, integrations by
  kind+name+url, users by email, groups by name, boards by name)
- Skip duplicates with warnings rather than failing
- Onboarding flow skips user creation step when config includes users
- Reuse existing admin group during onboarding after config import
This commit is contained in:
Thomas Camlong committed 2026-06-06 16:22:59 +02:00
1 parent 1f7b7f0516
commit f6a598b774
48 files changed
+1869 -1435

No files matched your search

@@ -13,6 +13,31 @@ const compatibilityAlertColor: Record<ConfigImportPreview["compatibility"]["stat
invalidStructure: "red",
};
type CountSection = { labelKey: string; entries: { key: string; count: number }[] };
const capitalize = (s: string) => s.charAt(0).toUpperCase() + s.slice(1);
const buildCountSections = (preview: ConfigImportPreview): CountSection[] => [
{
labelKey: "management.page.importExport.preview.importWillReuse",
entries: Object.entries(preview.toReuse)
.filter(([, count]) => count > 0)
.map(([key, count]) => ({ key: `management.page.importExport.preview.reuse${capitalize(key)}`, count })),
},
{
labelKey: "management.page.importExport.preview.importWillSkip",
entries: Object.entries(preview.toSkip)
.filter(([, count]) => count > 0)
.map(([key, count]) => ({ key: `management.page.importExport.preview.skip${capitalize(key)}`, count })),
},
{
labelKey: "management.page.importExport.preview.importWillUpdate",
entries: Object.entries(preview.toUpdate)
.filter(([, count]) => count > 0)
.map(([key, count]) => ({ key: `management.page.importExport.preview.update${capitalize(key)}`, count })),
},
];
type ConfigImportPreviewPanelProps = {
preview: ConfigImportPreview | undefined;
previewLoading: boolean;
@@ -78,69 +103,22 @@ export const ConfigImportPreviewPanel = ({ preview, previewLoading, t }: ConfigI
<ConfigSummaryList counts={preview.toCreate} t={t} />
</Stack>
{(preview.toReuse.apps > 0 || preview.toReuse.integrations > 0) && (
<Stack gap="xs">
<Text size="sm" fw={500}>
{t("management.page.importExport.preview.importWillReuse")}
</Text>
<List size="sm">
{preview.toReuse.apps > 0 && (
<List.Item>
{t("management.page.importExport.preview.reuseApps", { count: preview.toReuse.apps })}
</List.Item>
)}
{preview.toReuse.integrations > 0 && (
<List.Item>
{t("management.page.importExport.preview.reuseIntegrations", {
count: preview.toReuse.integrations,
})}
</List.Item>
)}
</List>
</Stack>
)}
{(preview.toSkip.boards > 0 || preview.toSkip.groups > 0 || preview.toSkip.searchEngines > 0) && (
<Stack gap="xs">
<Text size="sm" fw={500}>
{t("management.page.importExport.preview.importWillSkip")}
</Text>
<List size="sm">
{preview.toSkip.boards > 0 && (
<List.Item>
{t("management.page.importExport.preview.skipBoards", { count: preview.toSkip.boards })}
</List.Item>
)}
{preview.toSkip.groups > 0 && (
<List.Item>
{t("management.page.importExport.preview.skipGroups", { count: preview.toSkip.groups })}
</List.Item>
)}
{preview.toSkip.searchEngines > 0 && (
<List.Item>
{t("management.page.importExport.preview.skipSearchEngines", {
count: preview.toSkip.searchEngines,
})}
</List.Item>
)}
</List>
</Stack>
)}
{preview.toUpdate.serverSettings > 0 && (
<Stack gap="xs">
<Text size="sm" fw={500}>
{t("management.page.importExport.preview.importWillUpdate")}
</Text>
<List size="sm">
<List.Item>
{t("management.page.importExport.preview.updateServerSettings", {
count: preview.toUpdate.serverSettings,
})}
</List.Item>
</List>
</Stack>
)}
{buildCountSections(preview)
.filter((section) => section.entries.length > 0)
.map((section) => (
<Stack gap="xs" key={section.labelKey}>
<Text size="sm" fw={500}>
{t(section.labelKey)}
</Text>
<List size="sm">
{section.entries.map((entry) => (
<List.Item key={entry.key}>
{t(entry.key, { count: entry.count })}
</List.Item>
))}
</List>
</Stack>
))}
{preview.warnings.length > 0 && (
<Alert color="yellow" variant="light" icon={<IconAlertTriangle size="1rem" />}>
@@ -2,23 +2,10 @@
import { List, Text } from "@mantine/core";
import type { ConfigEntityCounts } from "@homarr/board-portability";
import type { ConfigEntityCounts, ConfigEntityKey } from "@homarr/board-portability";
import { CONFIG_ENTITY_KEYS } from "@homarr/board-portability";
const countLabelKeys: Record<keyof ConfigEntityCounts, string> = {
boards: "management.page.importExport.summary.boards",
apps: "management.page.importExport.summary.apps",
integrations: "management.page.importExport.summary.integrations",
secrets: "management.page.importExport.summary.secrets",
widgets: "management.page.importExport.summary.widgets",
sections: "management.page.importExport.summary.sections",
layouts: "management.page.importExport.summary.layouts",
searchEngines: "management.page.importExport.summary.searchEngines",
groups: "management.page.importExport.summary.groups",
serverSettings: "management.page.importExport.summary.serverSettings",
};
const countEntries = (counts: ConfigEntityCounts) =>
(Object.keys(countLabelKeys) as (keyof ConfigEntityCounts)[]).filter((key) => counts[key] > 0);
const TRANSLATION_PREFIX = "management.page.importExport.summary";
type ConfigSummaryListProps = {
counts: ConfigEntityCounts;
@@ -26,21 +13,21 @@ type ConfigSummaryListProps = {
};
export const ConfigSummaryList = ({ counts, t }: ConfigSummaryListProps) => {
const entries = countEntries(counts);
const entries = CONFIG_ENTITY_KEYS.filter((key) => counts[key] > 0);
if (entries.length === 0) {
return (
<Text size="sm" c="dimmed">
{t("management.page.importExport.summary.empty")}
{t(`${TRANSLATION_PREFIX}.empty`)}
</Text>
);
}
return (
<List size="sm" spacing={4}>
{entries.map((key) => (
{entries.map((key: ConfigEntityKey) => (
<List.Item key={key}>
{t(countLabelKeys[key], { count: counts[key] })}
{t(`${TRANSLATION_PREFIX}.${key}`, { count: counts[key] })}
</List.Item>
))}
</List>