mirror of
https://github.com/ajnart/homarr.git
synced 2025-11-14 17:26:26 +01:00
✨ Add password meter to onboarding
This commit is contained in:
@@ -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": {
|
||||||
|
|||||||
7
public/locales/en/password-requirements.json
Normal file
7
public/locales/en/password-requirements.json
Normal 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"
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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%"
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
24
src/components/Password/password-requirement.tsx
Normal file
24
src/components/Password/password-requirement.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
39
src/components/Password/password-requirements.tsx
Normal file
39
src/components/Password/password-requirements.tsx
Normal 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}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
Reference in New Issue
Block a user