diff --git a/src/components/layout/Common/useGradient.tsx b/src/components/layout/Common/useGradient.tsx
index 8675a6447..419e3bc1a 100644
--- a/src/components/layout/Common/useGradient.tsx
+++ b/src/components/layout/Common/useGradient.tsx
@@ -2,12 +2,12 @@ import { MantineGradient } from '@mantine/core';
import { useColorTheme } from '../../../tools/color';
-export const usePrimaryGradient = (): MantineGradient => {
+export const usePrimaryGradient = () => {
const { primaryColor, secondaryColor } = useColorTheme();
return {
from: primaryColor,
to: secondaryColor,
deg: 145,
- };
+ } satisfies MantineGradient;
};
diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx
index cc7927748..5755fc19d 100644
--- a/src/pages/_app.tsx
+++ b/src/pages/_app.tsx
@@ -16,6 +16,7 @@ import { AppProps } from 'next/app';
import Head from 'next/head';
import { useEffect, useState } from 'react';
import 'video.js/dist/video-js.css';
+import { z } from 'zod';
import { CommonHead } from '~/components/layout/Meta/CommonHead';
import { env } from '~/env.js';
import { ColorSchemeProvider } from '~/hooks/use-colorscheme';
diff --git a/src/pages/onboard.tsx b/src/pages/onboard.tsx
new file mode 100644
index 000000000..6b8523af7
--- /dev/null
+++ b/src/pages/onboard.tsx
@@ -0,0 +1,240 @@
+import {
+ Box,
+ Button,
+ Card,
+ Center,
+ Flex,
+ Grid,
+ Group,
+ Image,
+ PasswordInput,
+ Stack,
+ Text,
+ TextInput,
+ Title,
+ UnstyledButton,
+ createStyles,
+ useMantineTheme,
+} from '@mantine/core';
+import { useForm } from '@mantine/form';
+import { useMediaQuery } from '@mantine/hooks';
+import { IconLayoutDashboard, IconUserCog } from '@tabler/icons-react';
+import { IconArrowRight, IconBook2, IconUserPlus } from '@tabler/icons-react';
+import { GetServerSideProps } from 'next';
+import Link from 'next/link';
+import { ReactNode, useMemo, useState } from 'react';
+import { z } from 'zod';
+import { prisma } from '~/server/db';
+import { getServerSideTranslations } from '~/tools/server/getServerSideTranslations';
+import { onboardNamespaces } from '~/tools/server/translation-namespaces';
+import { api } from '~/utils/api';
+import { useI18nZodResolver } from '~/utils/i18n-zod-resolver';
+import { signUpFormSchema } from '~/validations/user';
+
+const getStepContents = () => [FirstStepContent, SecondStepContent, ThirdStepContent] as const;
+
+export default function OnboardPage() {
+ const { fn, colors, breakpoints, colorScheme } = useMantineTheme();
+ const [currentStep, setStep] = useState(0);
+ const next = () => setStep((prev) => prev + 1);
+ const isSmallerThanMd = useMediaQuery(`(max-width: ${breakpoints.sm})`);
+ const stepContents = useMemo(() => getStepContents(), []);
+ const CurrentStepComponent = useMemo(() => stepContents[currentStep], [currentStep]);
+ const background = colorScheme === 'dark' ? 'dark.6' : 'gray.1';
+
+ return (
+
+
+
+
+
+
+
+
+ {stepContents.map((_, index) => (
+
+ ))}
+
+
+
+
+ );
+}
+
+type StepProps = {
+ isCurrent: boolean;
+ isMobile: boolean;
+ isDark: boolean;
+};
+const Step = ({ isCurrent, isMobile, isDark }: StepProps) => {
+ return (
+
+ );
+};
+
+type StepContentComponent = (props: { isMobile: boolean; next: () => void }) => ReactNode;
+
+const FirstStepContent: StepContentComponent = ({ isMobile, next }) => {
+ return (
+ <>
+
+ Hi there!
+ Welcome to Homarr! 👋
+
+
+ Before you can use Homarr, you need to configure a few things.
+
+
+ >
+ );
+};
+
+const SecondStepContent: StepContentComponent = ({ isMobile, next }) => {
+ const { mutateAsync, isLoading } = api.user.createAdminAccount.useMutation();
+ const { i18nZodResolver } = useI18nZodResolver();
+
+ const form = useForm>({
+ validate: i18nZodResolver(signUpFormSchema),
+ validateInputOnBlur: true,
+ });
+ const handleSubmit = (values: z.infer) => {
+ void mutateAsync(values, {
+ onSuccess: () => {
+ next();
+ },
+ });
+ };
+
+ return (
+ <>
+ Configure your credentials
+
+ >
+ );
+};
+
+const firstActions = [
+ {
+ icon: IconBook2,
+ label: 'Read the documentation',
+ href: 'https://homarr.dev/docs/introduction/after-the-installation',
+ },
+ {
+ icon: IconUserPlus,
+ label: 'Invite an user',
+ href: '/users/invite',
+ },
+ {
+ icon: IconLayoutDashboard,
+ label: 'Setup your board',
+ href: '/board',
+ },
+ {
+ icon: IconUserCog,
+ label: 'Configure your profile',
+ href: '/user/preferences',
+ },
+];
+
+const ThirdStepContent: StepContentComponent = ({ isMobile, next }) => {
+ const { breakpoints } = useMantineTheme();
+ const { classes } = useStyles();
+
+ return (
+ <>
+ Get started! 🚀
+
+ {firstActions.map((action) => (
+
+
+
+
+
+
+ {action.label}
+
+
+
+
+
+
+
+ ))}
+
+ >
+ );
+};
+
+const useStyles = createStyles((theme) => ({
+ button: {
+ '&:hover': {
+ backgroundColor: theme.colorScheme === 'dark' ? theme.colors.dark[5] : theme.colors.gray[1],
+ },
+ },
+}));
+
+export const getServerSideProps: GetServerSideProps = async (ctx) => {
+ const userCount = await prisma.user.count();
+ if (userCount >= 1) {
+ return {
+ notFound: true,
+ };
+ }
+
+ const translations = await getServerSideTranslations(
+ onboardNamespaces,
+ ctx.locale,
+ ctx.req,
+ ctx.res
+ );
+
+ return {
+ props: {
+ ...translations,
+ },
+ };
+};
diff --git a/src/server/api/routers/user.ts b/src/server/api/routers/user.ts
index 95c2392ab..5108766aa 100644
--- a/src/server/api/routers/user.ts
+++ b/src/server/api/routers/user.ts
@@ -1,12 +1,7 @@
+import { UserSettings } from '@prisma/client';
import { TRPCError } from '@trpc/server';
-
import bcrypt from 'bcrypt';
-
import { z } from 'zod';
-
-import { createTRPCRouter, protectedProcedure, publicProcedure } from '../trpc';
-import { COOKIE_COLOR_SCHEME_KEY, COOKIE_LOCALE_KEY } from '../../../../data/constants';
-
import { hashPassword } from '~/utils/security';
import {
colorSchemeParser,
@@ -15,7 +10,26 @@ import {
updateSettingsValidationSchema,
} from '~/validations/user';
+import { COOKIE_COLOR_SCHEME_KEY, COOKIE_LOCALE_KEY } from '../../../../data/constants';
+import { TRPCContext, createTRPCRouter, protectedProcedure, publicProcedure } from '../trpc';
+
export const userRouter = createTRPCRouter({
+ createAdminAccount: publicProcedure.input(signUpFormSchema).mutation(async ({ ctx, input }) => {
+ const userCount = await ctx.prisma.user.count();
+ if (userCount > 0) {
+ throw new TRPCError({
+ code: 'FORBIDDEN',
+ });
+ }
+
+ await createUserInNotExist(ctx, input, {
+ defaultSettings: {
+ colorScheme: colorSchemeParser.parse(ctx.cookies[COOKIE_COLOR_SCHEME_KEY]),
+ language: ctx.cookies[COOKIE_LOCALE_KEY] ?? 'en',
+ },
+ isAdmin: true,
+ });
+ }),
createFromInvite: publicProcedure
.input(
signUpFormSchema.and(
@@ -38,19 +52,13 @@ export const userRouter = createTRPCRouter({
});
}
- const existingUser = await ctx.prisma.user.findFirst({
- where: {
- name: input.username,
+ await createUserInNotExist(ctx, input, {
+ defaultSettings: {
+ colorScheme: colorSchemeParser.parse(ctx.cookies[COOKIE_COLOR_SCHEME_KEY]),
+ language: ctx.cookies[COOKIE_LOCALE_KEY] ?? 'en',
},
});
- if (existingUser) {
- throw new TRPCError({
- code: 'CONFLICT',
- message: 'User already exists',
- });
- }
-
const salt = bcrypt.genSaltSync(10);
const hashedPassword = hashPassword(input.password, salt);
@@ -217,19 +225,7 @@ export const userRouter = createTRPCRouter({
};
}),
create: publicProcedure.input(createNewUserSchema).mutation(async ({ ctx, input }) => {
- const salt = bcrypt.genSaltSync(10);
- const hashedPassword = hashPassword(input.password, salt);
- await ctx.prisma.user.create({
- data: {
- name: input.username,
- email: input.email,
- password: hashedPassword,
- salt: salt,
- settings: {
- create: {},
- },
- },
- });
+ await createUserInNotExist(ctx, input);
}),
deleteUser: publicProcedure
@@ -246,3 +242,40 @@ export const userRouter = createTRPCRouter({
});
}),
});
+
+const createUserInNotExist = async (
+ ctx: TRPCContext,
+ input: z.infer,
+ options: {
+ defaultSettings?: Partial;
+ isAdmin?: boolean;
+ } | void
+) => {
+ const existingUser = await ctx.prisma.user.findFirst({
+ where: {
+ name: input.username,
+ },
+ });
+
+ if (existingUser) {
+ throw new TRPCError({
+ code: 'CONFLICT',
+ message: 'User already exists',
+ });
+ }
+
+ const salt = bcrypt.genSaltSync(10);
+ const hashedPassword = hashPassword(input.password, salt);
+ await ctx.prisma.user.create({
+ data: {
+ name: input.username,
+ email: input.email,
+ password: hashedPassword,
+ salt: salt,
+ isAdmin: options?.isAdmin ?? false,
+ settings: {
+ create: options?.defaultSettings ?? {},
+ },
+ },
+ });
+};
diff --git a/src/server/api/trpc.ts b/src/server/api/trpc.ts
index a1ec1e477..610ec4d05 100644
--- a/src/server/api/trpc.ts
+++ b/src/server/api/trpc.ts
@@ -44,6 +44,8 @@ const createInnerTRPCContext = (opts: CreateContextOptions) => ({
prisma,
});
+export type TRPCContext = ReturnType;
+
/**
* This is the actual context you will use in your router. It will be used to process every request
* that goes through your tRPC endpoint.
diff --git a/src/tools/server/translation-namespaces.ts b/src/tools/server/translation-namespaces.ts
index 1f0959322..975f6c20a 100644
--- a/src/tools/server/translation-namespaces.ts
+++ b/src/tools/server/translation-namespaces.ts
@@ -53,3 +53,5 @@ export const manageNamespaces = ['user/preferences', 'zod'];
export const loginNamespaces = ['authentication/login', 'zod'];
export const inviteNamespaces = ['authentication/invite', 'zod'];
+
+export const onboardNamespaces = ['common', 'zod'];