fix(onboarding): replace stepper with progress bar and fix finish flow

- Replace Mantine Stepper with a Progress bar + current integration
  avatar/name. The stepper broke layout with 10+ integrations on mobile.
- Remove useEffect for setupIntegrations — call it directly when
  advancing past the last integration or clicking skip, avoiding
  double-call risks (cf. Cloudflare outage pattern).
- Call router.refresh() after setupIntegrations so the server-rendered
  InitFinish component renders properly with login links.
- Remove DB query from init-finish.tsx — redirect to root path instead
  of looking up an arbitrary board name.
This commit is contained in:
Thomas Camlong committed 2026-05-14 16:54:22 +02:00
1 parent 34500366b4
commit 947504a16e
2 files changed
+38 -113

No files matched your search

@@ -4,27 +4,21 @@ import { IconBook2, IconLayoutDashboard, IconMailForward } from "@tabler/icons-r
import { isProviderEnabled } from "@homarr/auth/server";
import { getMantineColor } from "@homarr/common";
import { db } from "@homarr/db";
import { createDocumentationLink } from "@homarr/definitions";
import { getScopedI18n } from "@homarr/translation/server";
import { Link } from "@homarr/ui";
import type { TablerIcon } from "@homarr/ui";
export const InitFinish = async () => {
const firstBoard = await db.query.boards.findFirst({ columns: { name: true } });
const tFinish = await getScopedI18n("init.step.finish");
const boardName = firstBoard?.name ?? "dashboard";
return (
<Card w={64 * 6} maw="90vw" withBorder>
<Stack>
<Text>{tFinish("description")}</Text>
<InternalLinkButton
href={`/auth/login?callbackUrl=/boards/${boardName}`}
iconProps={{ icon: IconLayoutDashboard, color: "blue" }}
>
{tFinish("action.goToBoard", { name: boardName })}
<InternalLinkButton href="/auth/login?callbackUrl=/" iconProps={{ icon: IconLayoutDashboard, color: "blue" }}>
{tFinish("action.goToBoard", { name: "dashboard" })}
</InternalLinkButton>
{isProviderEnabled("credentials") && (
@@ -1,15 +1,15 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { Button, Card, Center, Group, Loader, Stack, Stepper, Text } from "@mantine/core";
import { IconArrowRight, IconBook2, IconCheck, IconLayoutDashboard, IconMailForward } from "@tabler/icons-react";
import { useCallback, useState } from "react";
import { useRouter } from "next/navigation";
import { Button, Card, Center, Group, Loader, Progress, Stack, Text } from "@mantine/core";
import { IconArrowRight } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { getMantineColor } from "@homarr/common";
import type { IntegrationKind } from "@homarr/definitions";
import { createDocumentationLink, getIntegrationName } from "@homarr/definitions";
import { getIntegrationName } from "@homarr/definitions";
import { useScopedI18n } from "@homarr/translation/client";
import { IntegrationAvatar, Link } from "@homarr/ui";
import { IntegrationAvatar } from "@homarr/ui";
import { NewIntegrationForm } from "~/app/[locale]/manage/integrations/new/_integration-new-form";
import { IntegrationMultiSelectGrid } from "~/components/integration/integration-multi-select-grid";
@@ -21,6 +21,7 @@ export const InitIntegrations = () => {
const [selectedKinds, setSelectedKinds] = useState<IntegrationKind[]>([]);
const [currentIndex, setCurrentIndex] = useState(0);
const t = useScopedI18n("init.step.integrations");
const router = useRouter();
const { mutateAsync: setupIntegrations } = clientApi.onboard.setupIntegrations.useMutation();
@@ -33,13 +34,15 @@ export const InitIntegrations = () => {
setPhase("configure");
};
const handleSkipAll = () => {
const finishSetupAsync = async () => {
setPhase("done");
await setupIntegrations();
router.refresh();
};
const advanceOrFinish = () => {
if (currentIndex + 1 >= selectedKinds.length) {
setPhase("done");
void finishSetupAsync();
} else {
setCurrentIndex((i) => i + 1);
}
@@ -51,44 +54,43 @@ export const InitIntegrations = () => {
};
if (phase === "done") {
return <FinishingPhase setupIntegrations={setupIntegrations} />;
return (
<Card w={64 * 8} maw="90vw" withBorder>
<Center py="xl">
<Stack align="center" gap="md">
<Loader size="lg" />
<Text>{t("configure.finishing")}</Text>
</Stack>
</Center>
</Card>
);
}
if (phase === "configure") {
const currentKind = selectedKinds[currentIndex];
if (currentKind === undefined) {
return (
<Card w={64 * 14} maw="90vw" withBorder>
<Center py="xl">
<Loader />
</Center>
</Card>
);
}
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- currentIndex is always within bounds of selectedKinds
const currentKind = selectedKinds[currentIndex]!;
const progress = ((currentIndex + 1) / selectedKinds.length) * 100;
return (
<Card w={64 * 14} maw="90vw" withBorder>
<Stack>
<Stepper active={currentIndex} size="sm" styles={{ stepIcon: { borderWidth: 0 } }}>
{selectedKinds.map((kind, index) => (
<Stepper.Step
key={kind}
icon={<IntegrationAvatar kind={kind} size="sm" />}
completedIcon={<IconCheck size={14} />}
label={index === currentIndex ? getIntegrationName(kind) : undefined}
/>
))}
</Stepper>
<Text size="sm" c="dimmed" ta="center">
{t("configure.progress", { current: String(currentIndex + 1), total: String(selectedKinds.length) })}
</Text>
<Stack gap="xs">
<Group gap="xs" justify="center">
<IntegrationAvatar kind={currentKind} size="sm" />
<Text fw={500}>{getIntegrationName(currentKind)}</Text>
</Group>
<Progress value={progress} size="sm" />
<Text size="sm" c="dimmed" ta="center">
{t("configure.progress", { current: String(currentIndex + 1), total: String(selectedKinds.length) })}
</Text>
</Stack>
<NewIntegrationForm
key={currentKind}
kind={currentKind}
onSuccess={advanceOrFinish}
onCancel={backToSelect}
isOnboarding
/>
</Stack>
</Card>
@@ -103,7 +105,7 @@ export const InitIntegrations = () => {
<IntegrationMultiSelectGrid onSelectionChange={handleSelectionChange} />
<Group justify="space-between">
<Button variant="subtle" onClick={handleSkipAll}>
<Button variant="subtle" onClick={() => void finishSetupAsync()}>
{t("action.skip")}
</Button>
<Button
@@ -118,74 +120,3 @@ export const InitIntegrations = () => {
</Card>
);
};
const FinishingPhase = ({ setupIntegrations }: { setupIntegrations: () => Promise<void> }) => {
const t = useScopedI18n("init.step");
const [isReady, setIsReady] = useState(false);
const calledRef = useRef(false);
useEffect(() => {
if (calledRef.current) return;
calledRef.current = true;
void setupIntegrations().then(() => {
setIsReady(true);
});
}, [setupIntegrations]);
if (!isReady) {
return (
<Card w={64 * 8} maw="90vw" withBorder>
<Center py="xl">
<Stack align="center" gap="md">
<Loader size="lg" />
<Text>{t("integrations.configure.finishing")}</Text>
</Stack>
</Center>
</Card>
);
}
return (
<Card w={64 * 8} maw="90vw" withBorder>
<Stack>
<Stack gap={4} align="center">
<IconCheck size={48} color={getMantineColor("green", 6)} />
<Text fw={500} size="lg">
{t("finish.title")}
</Text>
<Text size="sm" c="dimmed" ta="center">
{t("finish.description")}
</Text>
</Stack>
<Button
variant="default"
component={Link}
href="/auth/login?callbackUrl=/boards/dashboard"
leftSection={<IconLayoutDashboard color={getMantineColor("blue", 6)} size={16} stroke={1.5} />}
>
{t("finish.action.goToBoard", { name: "dashboard" })}
</Button>
<Button
variant="default"
component={Link}
href="/auth/login?callbackUrl=/manage/users/invites"
leftSection={<IconMailForward color={getMantineColor("pink", 6)} size={16} stroke={1.5} />}
>
{t("finish.action.inviteUser")}
</Button>
<Button
variant="default"
component="a"
href={createDocumentationLink("/docs/getting-started/after-the-installation")}
leftSection={<IconBook2 color={getMantineColor("yellow", 6)} size={16} stroke={1.5} />}
>
{t("finish.action.docs")}
</Button>
</Stack>
</Card>
);
};