fix: harden onboarding completion
This commit is contained in:
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);
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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}>
|
||||
|
||||
Reference in new issue
Block a user