Files
homarr/packages/onboarding/src/account.tsx
T

169 lines
5.6 KiB
TypeScript

"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";
import { clientApi } from "@homarr/api/client";
import { signIn } from "@homarr/auth/client";
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";
const AccountShell = ({
title,
description,
children,
}: {
title: string;
description: string;
children: React.ReactNode;
}) => (
<main className={classes.page}>
<div className={classes.shell}>
<Stack mih="calc(100dvh - 6rem)" justify="center" align="center">
<Paper className={classes.studio} radius="lg" p={{ base: "lg", sm: "xl" }} w="100%" maw="36rem">
<Stack gap="xl">
<Group wrap="nowrap" align="flex-start">
<ThemeIcon size="xl" radius="lg" variant="light">
<IconShieldCheck size={24} />
</ThemeIcon>
<Stack gap={4}>
<Title order={1} size="h2">
{title}
</Title>
<Text c="dimmed">{description}</Text>
</Stack>
</Group>
{children}
</Stack>
</Paper>
</Stack>
</div>
</main>
);
export const AccountSetup = ({ environment }: OnboardingStudioProps) => {
if (environment.currentStep === "user") return <CredentialsSetup />;
return <ExternalGroupSetup />;
};
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: "" },
});
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 (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))}>
<Stack gap="md">
<TextInput label={t("username")} autoComplete="username" withAsterisk {...form.getInputProps("username")} />
<UserCreatePasswordFields
passwordInputProps={{ ...form.getInputProps("password"), autoComplete: "new-password" }}
confirmPasswordInputProps={{ ...form.getInputProps("confirmPassword"), autoComplete: "new-password" }}
/>
{mutation.error ? (
<Alert color="red" title={t("errorTitle")}>
{mutation.error.message}
</Alert>
) : null}
<Button type="submit" size="md" loading={mutation.isPending} rightSection={<IconArrowRight size={18} />}>
{t("create")}
</Button>
</Stack>
</form>
</AccountShell>
);
};
const ExternalGroupSetup = () => {
const t = useScopedI18n("init.studio.externalGroup");
const mutation = clientApi.group.createInitialExternalGroup.useMutation({
async onSuccess() {
await revalidatePathActionAsync("/init");
},
});
const form = useZodForm(groupCreateSchema, { initialValues: { name: "" } });
return (
<AccountShell title={t("title")} description={t("description")}>
<form onSubmit={form.onSubmit((values) => mutation.mutate(values))}>
<Stack gap="md">
<TextInput
label={t("name")}
description={t("nameDescription")}
leftSection={<IconUserPlus size={16} />}
withAsterisk
{...form.getInputProps("name")}
/>
{mutation.error ? (
<Alert color="red" title={t("errorTitle")}>
{mutation.error.message}
</Alert>
) : null}
<Button type="submit" size="md" loading={mutation.isPending} rightSection={<IconArrowRight size={18} />}>
{t("continue")}
</Button>
</Stack>
</form>
</AccountShell>
);
};