Add password meter to onboarding

This commit is contained in:
Manuel
2023-08-22 21:45:10 +02:00
parent e82f3d0ea9
commit 107c6c3995
10 changed files with 90 additions and 111 deletions

View File

@@ -15,14 +15,7 @@
"title": "Second step", "title": "Second step",
"text": "Password", "text": "Password",
"password": { "password": {
"label": "Password", "label": "Password"
"requirements": {
"number": "Includes number",
"lowercase": "Includes lowercase letter",
"uppercase": "Includes uppercase letter",
"special": "Includes special character",
"length": "Includes at least {{count}} characters"
}
} }
}, },
"finish": { "finish": {

View File

@@ -0,0 +1,7 @@
{
"number": "Includes number",
"lowercase": "Includes lowercase letter",
"uppercase": "Includes uppercase letter",
"special": "Includes special character",
"length": "Includes at least {{count}} characters"
}

View File

@@ -1,48 +1,13 @@
import { import { Button, Card, Flex, Group, PasswordInput, Popover } from '@mantine/core';
Box,
Button,
Card,
Flex,
Group,
PasswordInput,
Popover,
Progress,
Text,
} from '@mantine/core';
import { useForm } from '@mantine/form'; import { useForm } from '@mantine/form';
import { import { IconArrowLeft, IconArrowRight, IconDice, IconKey } from '@tabler/icons-react';
IconArrowLeft,
IconArrowRight,
IconCheck,
IconDice,
IconKey,
IconX,
} from '@tabler/icons-react';
import { useTranslation } from 'next-i18next'; import { useTranslation } from 'next-i18next';
import { useState } from 'react'; import { useState } from 'react';
import { z } from 'zod'; import { z } from 'zod';
import { PasswordRequirements } from '~/components/Password/password-requirements';
import { api } from '~/utils/api'; import { api } from '~/utils/api';
import { useI18nZodResolver } from '~/utils/i18n-zod-resolver'; import { useI18nZodResolver } from '~/utils/i18n-zod-resolver';
import { minPasswordLength, passwordSchema } from '~/validations/user'; import { passwordSchema } from '~/validations/user';
const requirements = [
{ re: /[0-9]/, label: 'number' },
{ re: /[a-z]/, label: 'lowercase' },
{ re: /[A-Z]/, label: 'uppercase' },
{ re: /[$&+,:;=?@#|'<>.^*()%!-]/, label: 'special' },
];
function getStrength(password: string) {
let multiplier = password.length >= minPasswordLength ? 0 : 1;
requirements.forEach((requirement) => {
if (!requirement.re.test(password)) {
multiplier += 1;
}
});
return Math.max(100 - (100 / (requirements.length + 1)) * multiplier, 10);
}
interface CreateAccountSecurityStepProps { interface CreateAccountSecurityStepProps {
defaultPassword: string; defaultPassword: string;
@@ -70,16 +35,6 @@ export const CreateAccountSecurityStep = ({
const { mutateAsync, isLoading } = api.password.generate.useMutation(); const { mutateAsync, isLoading } = api.password.generate.useMutation();
const [popoverOpened, setPopoverOpened] = useState(false); const [popoverOpened, setPopoverOpened] = useState(false);
const checks = requirements.map((requirement, index) => (
<PasswordRequirement
key={index}
label={requirement.label}
meets={requirement.re.test(form.values.password)}
/>
));
const strength = getStrength(form.values.password);
const color = strength === 100 ? 'teal' : strength > 50 ? 'yellow' : 'red';
return ( return (
<Card mih={400}> <Card mih={400}>
@@ -122,12 +77,7 @@ export const CreateAccountSecurityStep = ({
</div> </div>
</Popover.Target> </Popover.Target>
<Popover.Dropdown> <Popover.Dropdown>
<Progress color={color} value={strength} size={5} mb="xs" /> <PasswordRequirements value={form.values.password} />
<PasswordRequirement
label="length"
meets={form.values.password.length >= minPasswordLength}
/>
{checks}
</Popover.Dropdown> </Popover.Dropdown>
</Popover> </Popover>
@@ -153,26 +103,6 @@ export const CreateAccountSecurityStep = ({
); );
}; };
const PasswordRequirement = ({ meets, label }: { meets: boolean; label: string }) => {
const { t } = useTranslation('manage/users/create');
return (
<Text
color={meets ? 'teal' : 'red'}
sx={{ display: 'flex', alignItems: 'center' }}
mt={7}
size="sm"
>
{meets ? <IconCheck size="0.9rem" /> : <IconX size="0.9rem" />}{' '}
<Box ml={10}>
{t(`steps.security.password.requirements.${label}`, {
count: minPasswordLength,
})}
</Box>
</Text>
);
};
export const createAccountSecurityStepValidationSchema = z.object({ export const createAccountSecurityStepValidationSchema = z.object({
password: passwordSchema, password: passwordSchema,
}); });

View File

@@ -2,14 +2,13 @@ import { Stack, Stepper } from '@mantine/core';
import { useState } from 'react'; import { useState } from 'react';
import { StepCreateAccount } from './step-create-account'; import { StepCreateAccount } from './step-create-account';
import { StepDockerImport } from './step-docker-import';
import { StepDocumentation } from './step-documentation'; import { StepDocumentation } from './step-documentation';
import { StepOnboardingFinished } from './step-onboarding-finished'; import { StepOnboardingFinished } from './step-onboarding-finished';
import { StepUpdatePathMappings } from './step-update-path-mappings'; import { StepUpdatePathMappings } from './step-update-path-mappings';
export const OnboardingSteps = ({ isUpdate }: { isUpdate: boolean }) => { export const OnboardingSteps = ({ isUpdate }: { isUpdate: boolean }) => {
const [currentStep, setCurrentStep] = useState(0); const [currentStep, setCurrentStep] = useState(0);
const nextStep = () => setCurrentStep((current) => (current < 4 ? current + 1 : current)); const nextStep = () => setCurrentStep((current) => (current < 3 ? current + 1 : current));
const prevStep = () => setCurrentStep((current) => (current > 0 ? current - 1 : current)); const prevStep = () => setCurrentStep((current) => (current > 0 ? current - 1 : current));
return ( return (
@@ -31,9 +30,6 @@ export const OnboardingSteps = ({ isUpdate }: { isUpdate: boolean }) => {
<Stepper.Step label="Your account" description="Create an account"> <Stepper.Step label="Your account" description="Create an account">
<StepCreateAccount next={nextStep} previous={prevStep} /> <StepCreateAccount next={nextStep} previous={prevStep} />
</Stepper.Step> </Stepper.Step>
<Stepper.Step label="Docker import" description="Import applications from Docker">
<StepDockerImport next={nextStep} />
</Stepper.Step>
<Stepper.Step label="Documentation" description="Introduction into Homarr"> <Stepper.Step label="Documentation" description="Introduction into Homarr">
<StepDocumentation next={nextStep} /> <StepDocumentation next={nextStep} />
</Stepper.Step> </Stepper.Step>

View File

@@ -1,4 +1,4 @@
import { Button, Group, PasswordInput, Stack, TextInput, Title } from '@mantine/core'; import { Button, Card, Group, PasswordInput, Stack, TextInput, Title } from '@mantine/core';
import { useForm } from '@mantine/form'; import { useForm } from '@mantine/form';
import { IconArrowLeft, IconArrowRight } from '@tabler/icons-react'; import { IconArrowLeft, IconArrowRight } from '@tabler/icons-react';
import { signIn } from 'next-auth/react'; import { signIn } from 'next-auth/react';
@@ -8,6 +8,7 @@ import { api } from '~/utils/api';
import { useI18nZodResolver } from '~/utils/i18n-zod-resolver'; import { useI18nZodResolver } from '~/utils/i18n-zod-resolver';
import { signUpFormSchema } from '~/validations/user'; import { signUpFormSchema } from '~/validations/user';
import { PasswordRequirements } from '../Password/password-requirements';
import { OnboardingStepWrapper } from './common-wrapper'; import { OnboardingStepWrapper } from './common-wrapper';
export const StepCreateAccount = ({ export const StepCreateAccount = ({
@@ -22,6 +23,11 @@ export const StepCreateAccount = ({
const { i18nZodResolver } = useI18nZodResolver(); const { i18nZodResolver } = useI18nZodResolver();
const form = useForm<z.infer<typeof signUpFormSchema>>({ const form = useForm<z.infer<typeof signUpFormSchema>>({
initialValues: {
password: '',
username: '',
passwordConfirmation: '',
},
validate: i18nZodResolver(signUpFormSchema), validate: i18nZodResolver(signUpFormSchema),
validateInputOnBlur: true, validateInputOnBlur: true,
}); });
@@ -70,6 +76,10 @@ export const StepCreateAccount = ({
{...form.getInputProps('password')} {...form.getInputProps('password')}
/> />
<Card mb="lg" withBorder>
<PasswordRequirements value={form.values.password} />
</Card>
<PasswordInput <PasswordInput
size="md" size="md"
w="100%" w="100%"

View File

@@ -1,20 +0,0 @@
import { Button, Stack, Title } from '@mantine/core';
import { IconArrowRight } from '@tabler/icons-react';
import { OnboardingStepWrapper } from './common-wrapper';
export const StepDockerImport = ({ next }: { next: () => void }) => {
return (
<OnboardingStepWrapper>
<Title order={2} align="center" mb="lg">
Automatic container import
</Title>
<Stack align="center">
<Button onClick={next} rightIcon={<IconArrowRight size="1rem" />} fullWidth>
Next
</Button>
</Stack>
</OnboardingStepWrapper>
);
};

View File

@@ -0,0 +1,24 @@
import { Box, Text } from "@mantine/core";
import { IconCheck, IconX } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { minPasswordLength } from "~/validations/user";
export const PasswordRequirement = ({ meets, label }: { meets: boolean; label: string }) => {
const { t } = useTranslation('password-requirements');
return (
<Text
color={meets ? 'teal' : 'red'}
sx={{ display: 'flex', alignItems: 'center' }}
mt={7}
size="sm"
>
{meets ? <IconCheck size="0.9rem" /> : <IconX size="0.9rem" />}{' '}
<Box ml={10}>
{t(`${label}`, {
count: minPasswordLength,
})}
</Box>
</Text>
);
};

View File

@@ -0,0 +1,39 @@
import { Progress } from '@mantine/core';
import { minPasswordLength } from '~/validations/user';
import { PasswordRequirement } from './password-requirement';
const requirements = [
{ re: /[0-9]/, label: 'number' },
{ re: /[a-z]/, label: 'lowercase' },
{ re: /[A-Z]/, label: 'uppercase' },
{ re: /[$&+,:;=?@#|'<>.^*()%!-]/, label: 'special' },
];
function getStrength(password: string) {
let multiplier = password.length >= minPasswordLength ? 0 : 1;
requirements.forEach((requirement) => {
if (!requirement.re.test(password)) {
multiplier += 1;
}
});
return Math.max(100 - (100 / (requirements.length + 1)) * multiplier, 10);
}
export const PasswordRequirements = ({ value }: { value: string }) => {
const checks = requirements.map((requirement, index) => (
<PasswordRequirement key={index} label={requirement.label} meets={requirement.re.test(value)} />
));
const strength = getStrength(value);
const color = strength === 100 ? 'teal' : strength > 50 ? 'yellow' : 'red';
return (
<>
<Progress color={color} value={strength} size={5} mb="xs" />
<PasswordRequirement label="length" meets={value.length >= minPasswordLength} />
{checks}
</>
);
};

View File

@@ -139,7 +139,7 @@ export const getServerSideProps: GetServerSideProps = async (ctx) => {
} }
const translations = await getServerSideTranslations( const translations = await getServerSideTranslations(
manageNamespaces, [...manageNamespaces, 'password-requirements'],
ctx.locale, ctx.locale,
ctx.req, ctx.req,
ctx.res ctx.res

View File

@@ -77,7 +77,7 @@ export const getServerSideProps: GetServerSideProps = async (ctx) => {
const configs = files.map((file) => getConfig(file)); const configs = files.map((file) => getConfig(file));
const configSchemaVersions = configs.map((config) => config.schemaVersion); const configSchemaVersions = configs.map((config) => config.schemaVersion);
const translations = await getServerSideTranslations([], ctx.locale, ctx.req, ctx.res); const translations = await getServerSideTranslations(['password-requirements'], ctx.locale, ctx.req, ctx.res);
return { return {
props: { props: {