feat: unify authentication pages

This commit is contained in:
Thomas Camlong committed 2026-08-14 21:18:06 +02:00
1 parent 95489b692c
commit 409e8ed71a
8 files changed
+110 -80

No files matched your search

+2 -1
View File
@@ -220,7 +220,8 @@ Clicking the `Copy & close` button will copy the URL to your clipboard.
### Self-onboarding
Clicking the invitation link will bring up a form with fields for the username and password:
Clicking the invitation link opens a centered registration form that uses the same visual shell as Homarr's onboarding
and sign-in pages, with fields for the username and password:
![](./img/invitation-self-onboarding.png)
@@ -1,13 +1,13 @@
import { notFound } from "next/navigation";
import { Card, Center, Stack, Text, Title } from "@mantine/core";
import { IconUserPlus } from "@tabler/icons-react";
import { auth } from "@homarr/auth/next";
import { isProviderEnabled } from "@homarr/auth/server";
import { and, db, eq } from "@homarr/db";
import { invites } from "@homarr/db/schema";
import { OnboardingAuthShell } from "@homarr/onboarding";
import { getScopedI18n } from "@homarr/translation/server";
import { HomarrLogoWithTitle } from "~/components/layout/logo/homarr-logo";
import { RegistrationForm } from "./_registration-form";
interface InviteUsagePageProps {
@@ -48,25 +48,14 @@ export default async function InviteUsagePage(props: InviteUsagePageProps) {
const t = await getScopedI18n("user.page.invite");
return (
<Center>
<Stack align="center" mt="xl">
<HomarrLogoWithTitle size="lg" />
<Stack gap={6} align="center">
<Title order={3} fw={400} ta="center">
{t("title")}
</Title>
<Text size="sm" c="gray.5" ta="center">
{t("subtitle")}
</Text>
</Stack>
<Card w={64 * 6} maw="90vw">
<RegistrationForm invite={invite} />
</Card>
<Text size="xs" c="gray.5" ta="center">
{/* eslint-disable-next-line @typescript-eslint/no-non-null-assertion */}
{t("description", { username: invite.creator.name! })}
</Text>
</Stack>
</Center>
<OnboardingAuthShell
title={t("title")}
description={t("subtitle")}
icon={<IconUserPlus size={24} />}
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
footer={t("description", { username: invite.creator.name! })}
>
<RegistrationForm invite={invite} />
</OnboardingAuthShell>
);
}
@@ -1,15 +1,15 @@
import { redirect } from "next/navigation";
import { Alert, Card, Center, Code, Stack, Text, Title } from "@mantine/core";
import { Alert, Code, Text } from "@mantine/core";
import { IconLogin } from "@tabler/icons-react";
import { env } from "@homarr/auth/env";
import { auth } from "@homarr/auth/next";
import { OnboardingAuthShell } from "@homarr/onboarding";
import { getScopedI18n } from "@homarr/translation/server";
import { sanitizeRedirectionUrl } from "@homarr/validation/redirection-url";
import { env as appEnv } from "~/env";
import { HomarrLogoWithTitle } from "~/components/layout/logo/homarr-logo";
import { LoginForm } from "./_login-form";
interface LoginProps {
@@ -29,33 +29,20 @@ export default async function Login(props: LoginProps) {
const t = await getScopedI18n("user.page.login");
return (
<Center>
<Stack align="center" mt="xl">
<HomarrLogoWithTitle size="lg" />
<Stack gap={6} align="center">
<Title order={3} fw={400} ta="center">
{t("title")}
</Title>
<Text size="sm" c="gray.5" ta="center">
{t("subtitle")}
<OnboardingAuthShell title={t("title")} description={t("subtitle")} icon={<IconLogin size={24} />}>
{appEnv.DEMO_MODE ? (
<Alert icon={<IconLogin size={18} />} color="blue" variant="light">
<Text size="sm" fw={500}>
Demo mode is enabled. Sign in with username <Code>demo</Code> and password <Code>demo</Code>
</Text>
</Stack>
{appEnv.DEMO_MODE && (
<Alert icon={<IconLogin size={18} />} color="blue" variant="light" w={64 * 6} maw="90vw">
<Text size="sm" fw={500}>
Demo mode is enabled. Sign in with username <Code>demo</Code> and password <Code>demo</Code>
</Text>
</Alert>
)}
<Card w={64 * 6} maw="90vw">
<LoginForm
providers={env.AUTH_PROVIDERS}
oidcClientName={env.AUTH_OIDC_CLIENT_NAME}
isOidcAutoLoginEnabled={env.AUTH_OIDC_AUTO_LOGIN}
callbackUrl={searchParams.callbackUrl ?? "/"}
/>
</Card>
</Stack>
</Center>
</Alert>
) : null}
<LoginForm
providers={env.AUTH_PROVIDERS}
oidcClientName={env.AUTH_OIDC_CLIENT_NAME}
isOidcAutoLoginEnabled={env.AUTH_OIDC_AUTO_LOGIN}
callbackUrl={searchParams.callbackUrl ?? "/"}
/>
</OnboardingAuthShell>
);
}
+2 -2
View File
@@ -11,6 +11,7 @@ import "~/styles/color-scheme.scss";
import "~/styles/scroll-area.scss";
import { notFound } from "next/navigation";
import { ColorSchemeScript } from "@mantine/core";
import type { DayOfWeek } from "@mantine/dates";
import { NextIntlClientProvider } from "next-intl";
@@ -161,17 +162,16 @@ export default async function Layout(props: {
]);
return (
// Instead of ColorSchemScript we use data-mantine-color-scheme to prevent flickering
<html
lang={locale}
dir={direction}
data-mantine-color-scheme={colorScheme}
style={{
backgroundColor: colorScheme === "dark" ? "#242424" : colorScheme === "auto" ? undefined : "#fff",
}}
suppressHydrationWarning
>
<head>
<ColorSchemeScript defaultColorScheme={colorScheme} />
<meta name="homarr-website-url" content={publicUrls.homarrWebsiteUrl} />
{session ? (
<>
+5 -26
View File
@@ -1,7 +1,7 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { Alert, Button, Group, Paper, Stack, Text, TextInput, ThemeIcon, Title } from "@mantine/core";
import { Alert, Button, Stack, Text, TextInput } from "@mantine/core";
import { IconArrowRight, IconShieldCheck, IconUserPlus } from "@tabler/icons-react";
import type { z } from "zod/v4";
@@ -17,10 +17,9 @@ import { groupCreateSchema } from "@homarr/validation/group";
import { userInitSchema } from "@homarr/validation/user";
import { didCredentialsSignInFail } from "./account-recovery";
import { OnboardingBackdrop } from "./onboarding-backdrop";
import { OnboardingAuthShell } from "./onboarding-auth-shell";
import type { OnboardingStudioProps } from "./types";
import { useOnboardingSounds } from "./use-onboarding-sounds";
import classes from "./onboarding-studio.module.css";
const AccountShell = ({
title,
@@ -31,29 +30,9 @@ const AccountShell = ({
description: string;
children: React.ReactNode;
}) => (
<main className={classes.page}>
<OnboardingBackdrop />
<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>
<OnboardingAuthShell title={title} description={description} icon={<IconShieldCheck size={24} />}>
{children}
</OnboardingAuthShell>
);
export const AccountSetup = ({ environment }: OnboardingStudioProps) => {
+1
View File
@@ -1,2 +1,3 @@
export { OnboardingAuthShell } from "./onboarding-auth-shell";
export { OnboardingStudio } from "./onboarding-studio";
export type { OnboardingEnvironment, OnboardingStudioProps } from "./types";
@@ -0,0 +1,48 @@
import type { ReactNode } from "react";
import { Group, Paper, Stack, Text, ThemeIcon, Title } from "@mantine/core";
import { OnboardingBackdrop } from "./onboarding-backdrop";
import { OnboardingWordmark } from "./onboarding-wordmark";
import classes from "./onboarding-studio.module.css";
interface OnboardingAuthShellProps {
title: ReactNode;
description: ReactNode;
icon: ReactNode;
children: ReactNode;
footer?: ReactNode;
}
export const OnboardingAuthShell = ({ title, description, icon, children, footer }: OnboardingAuthShellProps) => (
<main className={classes.page}>
<OnboardingBackdrop />
<div className={`${classes.shell} ${classes.authShell}`}>
<Stack className={classes.authLayout} gap="xl" align="center" justify="center">
<div className={classes.authBrand}>
<OnboardingWordmark large />
</div>
<Paper className={`${classes.studio} ${classes.authCard}`} radius="lg" p={{ base: "lg", sm: "xl" }}>
<Stack gap="xl">
<Group wrap="nowrap" align="flex-start">
<ThemeIcon size="xl" radius="lg" variant="light">
{icon}
</ThemeIcon>
<Stack gap={4}>
<Title order={1} size="h2">
{title}
</Title>
<Text c="dimmed">{description}</Text>
</Stack>
</Group>
{children}
</Stack>
</Paper>
{footer ? (
<Text className={classes.authFooter} size="sm" c="dimmed" ta="center">
{footer}
</Text>
) : null}
</Stack>
</div>
</main>
);
@@ -129,6 +129,31 @@
overflow: clip;
}
.authShell {
display: grid;
min-height: 100dvh;
padding-block: clamp(1rem, 4vw, 3rem) calc(clamp(1rem, 4vw, 3rem) + env(safe-area-inset-bottom));
}
.authLayout {
width: min(100%, 36rem);
margin: auto;
}
.authBrand {
position: relative;
z-index: 1;
}
.authCard {
width: 100%;
}
.authFooter {
max-width: 32rem;
text-wrap: balance;
}
.studioGrid {
display: grid;
grid-template-columns: 15rem minmax(0, 1fr);