fix: harden onboarding completion

This commit is contained in:
Thomas Camlong authored and ajnart committed 2026-08-14 01:20:27 +02:00
1 parent 576cc0b418
commit 07770e862c
44 files changed
+3003 -1000

No files matched your search

@@ -0,0 +1,14 @@
import { describe, expect, it } from "vitest";
import { didCredentialsSignInFail } from "./account-recovery";
describe("didCredentialsSignInFail", () => {
it.each([
[undefined, true],
[{ ok: false }, true],
[{ ok: true, error: "CredentialsSignin" }, true],
[{ ok: true, error: undefined }, false],
] as const)("resolves %o to %s", (result, expected) => {
expect(didCredentialsSignInFail(result)).toBe(expected);
});
});
@@ -0,0 +1,7 @@
interface CredentialsSignInResult {
ok: boolean;
error?: string | null;
}
export const didCredentialsSignInFail = (result: CredentialsSignInResult | undefined) =>
result === undefined || !result.ok || Boolean(result.error);
+38 -4
View File
@@ -1,5 +1,6 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { Alert, Button, Group, Paper, Stack, Text, TextInput, ThemeIcon, Title } from "@mantine/core";
import { IconArrowRight, IconShieldCheck, IconUserPlus } from "@tabler/icons-react";
import type { z } from "zod/v4";
@@ -10,9 +11,11 @@ import { revalidatePathActionAsync } from "@homarr/common/client";
import { useZodForm } from "@homarr/form";
import { UserCreatePasswordFields } from "@homarr/forms-collection";
import { useScopedI18n } from "@homarr/translation/client";
import { Link } from "@homarr/ui";
import { groupCreateSchema } from "@homarr/validation/group";
import { userInitSchema } from "@homarr/validation/user";
import { didCredentialsSignInFail } from "./account-recovery";
import type { OnboardingStudioProps } from "./types";
import classes from "./onboarding-studio.module.css";
@@ -56,6 +59,8 @@ export const AccountSetup = ({ environment }: OnboardingStudioProps) => {
const CredentialsSetup = () => {
const t = useScopedI18n("init.studio.account");
const [requiresSignIn, setRequiresSignIn] = useState(false);
const recoveryActionRef = useRef<HTMLAnchorElement>(null);
const mutation = clientApi.user.initUser.useMutation();
const form = useZodForm(userInitSchema, {
initialValues: { username: "", password: "", confirmPassword: "" },
@@ -64,18 +69,47 @@ const CredentialsSetup = () => {
const submitAsync = async (values: z.infer<typeof userInitSchema>) => {
try {
await mutation.mutateAsync(values);
} catch (error) {
form.setErrors({ username: error instanceof Error ? error.message : t("unknownError") });
return;
}
try {
const result = await signIn("credentials", {
name: values.username,
password: values.password,
redirect: false,
});
if (result?.error) throw new Error(t("signInError"));
await revalidatePathActionAsync("/init");
} catch (error) {
form.setErrors({ username: error instanceof Error ? error.message : t("unknownError") });
if (didCredentialsSignInFail(result)) throw new Error();
} catch {
setRequiresSignIn(true);
await revalidatePathActionAsync("/init").catch(() => undefined);
return;
}
await revalidatePathActionAsync("/init");
};
useEffect(() => {
if (requiresSignIn) recoveryActionRef.current?.focus();
}, [requiresSignIn]);
if (requiresSignIn) {
return (
<AccountShell title={t("recoveryTitle")} description={t("recoveryDescription")}>
<Button
component={Link}
href="/auth/login?callbackUrl=/init"
ref={recoveryActionRef}
size="md"
rightSection={<IconArrowRight size={18} />}
>
{t("recoveryAction")}
</Button>
</AccountShell>
);
}
return (
<AccountShell title={t("title")} description={t("description")}>
<form onSubmit={form.onSubmit((values) => void submitAsync(values))}>
@@ -0,0 +1,26 @@
import { describe, expect, it } from "vitest";
import { getOnboardingAccessState } from "./claim-state";
describe("getOnboardingAccessState", () => {
it("requires existing installations to sign in even when a legacy step normalizes to start", () => {
expect(
getOnboardingAccessState({
currentStep: "start",
canConfigurePrivileged: false,
hasUsers: true,
externalAuthEnabled: false,
}),
).toBe("signIn");
});
it.each([
[{ currentStep: "start", canConfigurePrivileged: false, hasUsers: false, externalAuthEnabled: false }, "ready"],
[{ currentStep: "user", canConfigurePrivileged: false, hasUsers: false, externalAuthEnabled: false }, "claim"],
[{ currentStep: "setup", canConfigurePrivileged: false, hasUsers: false, externalAuthEnabled: true }, "signIn"],
[{ currentStep: "setup", canConfigurePrivileged: true, hasUsers: true, externalAuthEnabled: false }, "ready"],
[{ currentStep: "finish", canConfigurePrivileged: false, hasUsers: true, externalAuthEnabled: false }, "ready"],
] as const)("resolves %o to %s", (environment, expected) => {
expect(getOnboardingAccessState(environment)).toBe(expected);
});
});
+14
View File
@@ -0,0 +1,14 @@
import type { OnboardingEnvironment } from "./types";
export type OnboardingClaimState = "checking" | "ready" | "locked" | "signIn" | "error";
export const getOnboardingAccessState = (
environment: Pick<
OnboardingEnvironment,
"currentStep" | "canConfigurePrivileged" | "hasUsers" | "externalAuthEnabled"
>,
): "ready" | "signIn" | "claim" => {
if (environment.currentStep === "finish" || environment.canConfigurePrivileged) return "ready";
if (environment.hasUsers || (environment.externalAuthEnabled && environment.currentStep === "setup")) return "signIn";
return environment.currentStep === "start" ? "ready" : "claim";
};
@@ -0,0 +1,31 @@
import { describe, expect, it } from "vitest";
import { isHttpUrl, resolveDiscoveredAppUrl, takeNewSourceIds } from "./discovery-selection";
describe("takeNewSourceIds", () => {
it("keeps same-kind service instances independent and does not reselect them after refresh", () => {
const seen = new Set<string>();
const sonarrInstances = ["docker:host:sonarr-hd", "docker:host:sonarr-4k"];
expect(takeNewSourceIds(sonarrInstances, seen)).toEqual(sonarrInstances);
expect(takeNewSourceIds(sonarrInstances, seen)).toEqual([]);
});
it("keeps an explicit app URL and falls back through discovery and server-derived addresses", () => {
expect(resolveDiscoveredAppUrl("https://manual.example", "https://label.example", "http://host:3000")).toBe(
"https://manual.example",
);
expect(resolveDiscoveredAppUrl(undefined, "https://label.example", "http://host:3000")).toBe(
"https://label.example",
);
expect(resolveDiscoveredAppUrl(undefined, null, "http://host:3000")).toBe("http://host:3000");
expect(resolveDiscoveredAppUrl(undefined, null, null)).toBe("");
});
it("accepts only HTTP and HTTPS service addresses", () => {
expect(isHttpUrl("http://home.lan:8989")).toBe(true);
expect(isHttpUrl("https://home.example/sonarr")).toBe(true);
expect(isHttpUrl("home.lan:8989")).toBe(false);
expect(isHttpUrl("javascript:alert(1)")).toBe(false);
});
});
@@ -0,0 +1,21 @@
export const takeNewSourceIds = (sourceIds: string[], seenSourceIds: Set<string>) =>
sourceIds.filter((sourceId) => {
if (seenSourceIds.has(sourceId)) return false;
seenSourceIds.add(sourceId);
return true;
});
export const resolveDiscoveredAppUrl = (
override: string | undefined,
suggestedUrl: string | null,
generatedUrl: string | null,
) => override ?? suggestedUrl ?? generatedUrl ?? "";
export const isHttpUrl = (value: string) => {
try {
const protocol = new URL(value).protocol;
return protocol === "http:" || protocol === "https:";
} catch {
return false;
}
};
+1 -3
View File
@@ -15,9 +15,7 @@ export const Finish = ({ environment }: OnboardingStudioProps) => {
const t = useScopedI18n("init.studio.finish");
const reduceMotion = useReducedMotion();
const boardHref = `/boards/${encodeURIComponent(environment.initialBoard?.name ?? "dashboard")}`;
const destination = environment.canConfigurePrivileged
? boardHref
: `/auth/login?callbackUrl=${encodeURIComponent(boardHref)}`;
const destination = environment.canConfigurePrivileged ? boardHref : "/auth/login?callbackUrl=%2Finit";
return (
<main className={classes.page}>
@@ -163,6 +163,22 @@
border: 1px solid color-mix(in srgb, var(--mantine-color-default-border) 76%, transparent);
}
.discoveryChoice {
transition:
border-color 150ms ease,
background-color 150ms ease;
}
.discoveryChoice[data-checked] {
border-color: var(--mantine-primary-color-filled);
background: color-mix(in srgb, var(--mantine-primary-color-light) 62%, var(--mantine-color-body));
}
.discoveryChoice:focus-visible {
outline: 3px solid var(--mantine-primary-color-light-color);
outline-offset: 2px;
}
.choiceGrid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
@@ -208,6 +224,12 @@
border: 1px solid var(--mantine-color-default-border);
}
.preview[data-layout-role="mobile"] {
width: min(100%, 28rem);
margin-inline: auto;
aspect-ratio: 4 / 3;
}
.previewLane {
min-width: 0;
border-radius: calc(var(--mantine-radius-lg) * 0.65);
@@ -219,8 +241,8 @@
.previewTiles {
height: 100%;
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
grid-auto-rows: minmax(0.8rem, 1fr);
grid-auto-flow: dense;
gap: 0.3rem;
}
@@ -234,23 +256,6 @@
border: 1px solid color-mix(in srgb, var(--mantine-primary-color-filled) 22%, transparent);
}
.previewTile:nth-child(1) {
grid-column: span 2;
grid-row: span 2;
}
.previewTile:nth-child(2) {
grid-column: span 2;
}
.previewTile:nth-child(3) {
grid-column: span 1;
}
.previewTile:nth-child(4) {
grid-column: span 1;
}
.previewTile:nth-child(5) {
grid-column: span 4;
}
.assistantStage {
position: relative;
min-height: 13rem;
@@ -490,7 +495,8 @@
.choice,
.railButton,
.nextCard {
.nextCard,
.discoveryChoice {
transition: none;
}
+6 -18
View File
@@ -8,6 +8,7 @@ import { Link } from "@homarr/ui";
import type { OnboardingStudioProps } from "./types";
import { AccountSetup } from "./account";
import { getOnboardingAccessState } from "./claim-state";
import { Finish } from "./finish";
import { SetupStudio } from "./setup-studio";
import { Welcome } from "./welcome";
@@ -15,22 +16,15 @@ import { Welcome } from "./welcome";
export const OnboardingStudio = (props: OnboardingStudioProps) => {
const t = useScopedI18n("init.studio.claim");
const tCommon = useScopedI18n("common.action");
const accessState = getOnboardingAccessState(props.environment);
const [claimState, setClaimState] = useState<"checking" | "ready" | "locked" | "signIn" | "error">(
props.environment.currentStep === "start" || props.environment.currentStep === "finish" ? "ready" : "checking",
accessState === "claim" ? "checking" : accessState,
);
const [claimError, setClaimError] = useState<string | null>(null);
useEffect(() => {
if (props.environment.currentStep === "start" || props.environment.currentStep === "finish") return;
if (props.environment.canConfigurePrivileged) {
setClaimState("ready");
return;
}
if (
props.environment.hasUsers ||
(props.environment.externalAuthEnabled && props.environment.currentStep === "setup")
) {
setClaimState("signIn");
if (accessState !== "claim") {
setClaimState(accessState);
return;
}
@@ -51,13 +45,7 @@ export const OnboardingStudio = (props: OnboardingStudioProps) => {
setClaimState("error");
});
return () => controller.abort();
}, [
props.environment.canConfigurePrivileged,
props.environment.currentStep,
props.environment.externalAuthEnabled,
props.environment.hasUsers,
t,
]);
}, [accessState, t]);
if (claimState !== "ready") {
const signIn = claimState === "signIn";
File diff suppressed because it is too large. Load diff
-2
View File
@@ -5,11 +5,9 @@ import type { OnboardingStep } from "@homarr/definitions";
export interface OnboardingEnvironment {
currentStep: OnboardingStep;
databaseDriver: "sqlite" | "mysql" | "postgresql";
credentialsEnabled: boolean;
externalAuthEnabled: boolean;
dockerConfigured: boolean;
kubernetesConfigured: boolean;
workshopEnabled: boolean;
workshopApiUrl: string;
workshopUrl: string;
mcpEndpoint: string;
+1 -1
View File
@@ -74,7 +74,7 @@ export const Welcome = ({ environment, sqliteRestore }: OnboardingStudioProps) =
<section className={classes.hero} aria-labelledby="onboarding-welcome-title">
<Stack gap="xl" align="flex-start">
<Stack gap="md">
<Text c="dimmed" fw={650} tt="uppercase" fz="xs" lts="0.14em">
<Text c="dimmed" fw={650} size="sm">
{t("eyebrow")}
</Text>
<Title id="onboarding-welcome-title" className={classes.heroTitle}>