🚧 Add onboarding
This commit is contained in:
@@ -2,12 +2,12 @@ import { MantineGradient } from '@mantine/core';
|
|||||||
|
|
||||||
import { useColorTheme } from '../../../tools/color';
|
import { useColorTheme } from '../../../tools/color';
|
||||||
|
|
||||||
export const usePrimaryGradient = (): MantineGradient => {
|
export const usePrimaryGradient = () => {
|
||||||
const { primaryColor, secondaryColor } = useColorTheme();
|
const { primaryColor, secondaryColor } = useColorTheme();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
from: primaryColor,
|
from: primaryColor,
|
||||||
to: secondaryColor,
|
to: secondaryColor,
|
||||||
deg: 145,
|
deg: 145,
|
||||||
};
|
} satisfies MantineGradient;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { AppProps } from 'next/app';
|
|||||||
import Head from 'next/head';
|
import Head from 'next/head';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import 'video.js/dist/video-js.css';
|
import 'video.js/dist/video-js.css';
|
||||||
|
import { z } from 'zod';
|
||||||
import { CommonHead } from '~/components/layout/Meta/CommonHead';
|
import { CommonHead } from '~/components/layout/Meta/CommonHead';
|
||||||
import { env } from '~/env.js';
|
import { env } from '~/env.js';
|
||||||
import { ColorSchemeProvider } from '~/hooks/use-colorscheme';
|
import { ColorSchemeProvider } from '~/hooks/use-colorscheme';
|
||||||
|
|||||||
240
src/pages/onboard.tsx
Normal file
240
src/pages/onboard.tsx
Normal file
@@ -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 (
|
||||||
|
<Stack h="100dvh" bg={background} spacing={0}>
|
||||||
|
<Center bg={fn.linearGradient(145, colors.red[7], colors.red[5])} h="35%">
|
||||||
|
<Center bg={background} w={128} h={128} style={{ borderRadius: 64 }}>
|
||||||
|
<Image width={96} src="/imgs/logo/logo-color.svg" alt="Homarr Logo" />
|
||||||
|
</Center>
|
||||||
|
</Center>
|
||||||
|
<Stack spacing="xl" p="md" align="center">
|
||||||
|
<Group>
|
||||||
|
{stepContents.map((_, index) => (
|
||||||
|
<Step
|
||||||
|
key={index}
|
||||||
|
isCurrent={currentStep === index}
|
||||||
|
isMobile={isSmallerThanMd}
|
||||||
|
isDark={colorScheme === 'dark'}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Group>
|
||||||
|
<CurrentStepComponent isMobile={isSmallerThanMd} next={next} />
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type StepProps = {
|
||||||
|
isCurrent: boolean;
|
||||||
|
isMobile: boolean;
|
||||||
|
isDark: boolean;
|
||||||
|
};
|
||||||
|
const Step = ({ isCurrent, isMobile, isDark }: StepProps) => {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
h={isMobile ? 16 : 20}
|
||||||
|
w={isMobile ? 16 : 20}
|
||||||
|
bg={isCurrent ? 'red.6' : isDark ? 'dark.3' : 'gray.4'}
|
||||||
|
style={{ borderRadius: 10 }}
|
||||||
|
></Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
type StepContentComponent = (props: { isMobile: boolean; next: () => void }) => ReactNode;
|
||||||
|
|
||||||
|
const FirstStepContent: StepContentComponent = ({ isMobile, next }) => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Stack spacing={4} align="center">
|
||||||
|
<Title order={isMobile ? 3 : 1}>Hi there!</Title>
|
||||||
|
<Title order={isMobile ? 3 : 1}>Welcome to Homarr! 👋</Title>
|
||||||
|
</Stack>
|
||||||
|
<Text color="dimmed" size="lg" align="center">
|
||||||
|
Before you can use Homarr, you need to configure a few things.
|
||||||
|
</Text>
|
||||||
|
<Button onClick={next} size="lg" mt="sm" w={400} maw="90%">
|
||||||
|
Start configuration
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const SecondStepContent: StepContentComponent = ({ isMobile, next }) => {
|
||||||
|
const { mutateAsync, isLoading } = api.user.createAdminAccount.useMutation();
|
||||||
|
const { i18nZodResolver } = useI18nZodResolver();
|
||||||
|
|
||||||
|
const form = useForm<z.infer<typeof signUpFormSchema>>({
|
||||||
|
validate: i18nZodResolver(signUpFormSchema),
|
||||||
|
validateInputOnBlur: true,
|
||||||
|
});
|
||||||
|
const handleSubmit = (values: z.infer<typeof signUpFormSchema>) => {
|
||||||
|
void mutateAsync(values, {
|
||||||
|
onSuccess: () => {
|
||||||
|
next();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Title order={isMobile ? 3 : 1}>Configure your credentials</Title>
|
||||||
|
<form
|
||||||
|
style={{ width: '100%', display: 'flex', justifyContent: 'center' }}
|
||||||
|
onSubmit={form.onSubmit(handleSubmit)}
|
||||||
|
>
|
||||||
|
<Stack w={400} maw="90%" spacing="sm" align="center">
|
||||||
|
<TextInput
|
||||||
|
size="md"
|
||||||
|
w="100%"
|
||||||
|
label="Username"
|
||||||
|
withAsterisk
|
||||||
|
{...form.getInputProps('username')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<PasswordInput
|
||||||
|
size="md"
|
||||||
|
w="100%"
|
||||||
|
label="Password"
|
||||||
|
withAsterisk
|
||||||
|
{...form.getInputProps('password')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<PasswordInput
|
||||||
|
size="md"
|
||||||
|
w="100%"
|
||||||
|
label="Confirm password"
|
||||||
|
withAsterisk
|
||||||
|
{...form.getInputProps('passwordConfirmation')}
|
||||||
|
/>
|
||||||
|
<Button mt="sm" fullWidth type="submit" loading={isLoading}>
|
||||||
|
Continue
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</form>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<>
|
||||||
|
<Title order={isMobile ? 3 : 1}>Get started! 🚀</Title>
|
||||||
|
<Grid w="100%" maw={breakpoints.sm} mt="xl">
|
||||||
|
{firstActions.map((action) => (
|
||||||
|
<Grid.Col key={action.label} sm={6}>
|
||||||
|
<UnstyledButton component={Link} href={action.href} w="100%">
|
||||||
|
<Card withBorder className={classes.button}>
|
||||||
|
<Group position="apart">
|
||||||
|
<Group>
|
||||||
|
<action.icon size={isMobile ? 16 : 20} stroke={1.5} />
|
||||||
|
<Title order={isMobile ? 6 : 5}>{action.label}</Title>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<IconArrowRight size={isMobile ? 16 : 20} stroke={1.5} />
|
||||||
|
</Group>
|
||||||
|
</Card>
|
||||||
|
</UnstyledButton>
|
||||||
|
</Grid.Col>
|
||||||
|
))}
|
||||||
|
</Grid>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -1,12 +1,7 @@
|
|||||||
|
import { UserSettings } from '@prisma/client';
|
||||||
import { TRPCError } from '@trpc/server';
|
import { TRPCError } from '@trpc/server';
|
||||||
|
|
||||||
import bcrypt from 'bcrypt';
|
import bcrypt from 'bcrypt';
|
||||||
|
|
||||||
import { z } from 'zod';
|
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 { hashPassword } from '~/utils/security';
|
||||||
import {
|
import {
|
||||||
colorSchemeParser,
|
colorSchemeParser,
|
||||||
@@ -15,7 +10,26 @@ import {
|
|||||||
updateSettingsValidationSchema,
|
updateSettingsValidationSchema,
|
||||||
} from '~/validations/user';
|
} 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({
|
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
|
createFromInvite: publicProcedure
|
||||||
.input(
|
.input(
|
||||||
signUpFormSchema.and(
|
signUpFormSchema.and(
|
||||||
@@ -38,19 +52,13 @@ export const userRouter = createTRPCRouter({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const existingUser = await ctx.prisma.user.findFirst({
|
await createUserInNotExist(ctx, input, {
|
||||||
where: {
|
defaultSettings: {
|
||||||
name: input.username,
|
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 salt = bcrypt.genSaltSync(10);
|
||||||
const hashedPassword = hashPassword(input.password, salt);
|
const hashedPassword = hashPassword(input.password, salt);
|
||||||
|
|
||||||
@@ -217,19 +225,7 @@ export const userRouter = createTRPCRouter({
|
|||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
create: publicProcedure.input(createNewUserSchema).mutation(async ({ ctx, input }) => {
|
create: publicProcedure.input(createNewUserSchema).mutation(async ({ ctx, input }) => {
|
||||||
const salt = bcrypt.genSaltSync(10);
|
await createUserInNotExist(ctx, input);
|
||||||
const hashedPassword = hashPassword(input.password, salt);
|
|
||||||
await ctx.prisma.user.create({
|
|
||||||
data: {
|
|
||||||
name: input.username,
|
|
||||||
email: input.email,
|
|
||||||
password: hashedPassword,
|
|
||||||
salt: salt,
|
|
||||||
settings: {
|
|
||||||
create: {},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}),
|
}),
|
||||||
|
|
||||||
deleteUser: publicProcedure
|
deleteUser: publicProcedure
|
||||||
@@ -246,3 +242,40 @@ export const userRouter = createTRPCRouter({
|
|||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const createUserInNotExist = async (
|
||||||
|
ctx: TRPCContext,
|
||||||
|
input: z.infer<typeof createNewUserSchema>,
|
||||||
|
options: {
|
||||||
|
defaultSettings?: Partial<UserSettings>;
|
||||||
|
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 ?? {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|||||||
@@ -44,6 +44,8 @@ const createInnerTRPCContext = (opts: CreateContextOptions) => ({
|
|||||||
prisma,
|
prisma,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export type TRPCContext = ReturnType<typeof createInnerTRPCContext>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This is the actual context you will use in your router. It will be used to process every request
|
* 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.
|
* that goes through your tRPC endpoint.
|
||||||
|
|||||||
@@ -53,3 +53,5 @@ export const manageNamespaces = ['user/preferences', 'zod'];
|
|||||||
export const loginNamespaces = ['authentication/login', 'zod'];
|
export const loginNamespaces = ['authentication/login', 'zod'];
|
||||||
|
|
||||||
export const inviteNamespaces = ['authentication/invite', 'zod'];
|
export const inviteNamespaces = ['authentication/invite', 'zod'];
|
||||||
|
|
||||||
|
export const onboardNamespaces = ['common', 'zod'];
|
||||||
|
|||||||
Reference in New Issue
Block a user