mirror of
https://github.com/ajnart/homarr.git
synced 2025-11-17 10:41:10 +01:00
✨ Add password strength indicator and use crypto safe random PWs
This commit is contained in:
@@ -68,6 +68,7 @@
|
|||||||
"fily-publish-gridstack": "^0.0.13",
|
"fily-publish-gridstack": "^0.0.13",
|
||||||
"flag-icons": "^6.9.2",
|
"flag-icons": "^6.9.2",
|
||||||
"framer-motion": "^10.0.0",
|
"framer-motion": "^10.0.0",
|
||||||
|
"generate-password": "^1.7.0",
|
||||||
"html-entities": "^2.3.3",
|
"html-entities": "^2.3.3",
|
||||||
"i18next": "^22.5.1",
|
"i18next": "^22.5.1",
|
||||||
"js-file-download": "^0.4.12",
|
"js-file-download": "^0.4.12",
|
||||||
|
|||||||
@@ -1,7 +1,46 @@
|
|||||||
import { Button, Card, Flex, Group, PasswordInput } from '@mantine/core';
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Flex,
|
||||||
|
Group,
|
||||||
|
PasswordInput,
|
||||||
|
Popover,
|
||||||
|
Progress,
|
||||||
|
Text,
|
||||||
|
} from '@mantine/core';
|
||||||
import { useForm, zodResolver } from '@mantine/form';
|
import { useForm, zodResolver } from '@mantine/form';
|
||||||
import { IconArrowLeft, IconArrowRight, IconDice, IconKey } from '@tabler/icons-react';
|
import {
|
||||||
|
IconArrowLeft,
|
||||||
|
IconArrowRight,
|
||||||
|
IconCheck,
|
||||||
|
IconDice,
|
||||||
|
IconKey,
|
||||||
|
IconX,
|
||||||
|
} from '@tabler/icons-react';
|
||||||
|
import { useState } from 'react';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
import { api } from '~/utils/api';
|
||||||
|
import { passwordSchema } from '~/validations/user';
|
||||||
|
|
||||||
|
const requirements = [
|
||||||
|
{ re: /[0-9]/, label: 'Includes number' },
|
||||||
|
{ re: /[a-z]/, label: 'Includes lowercase letter' },
|
||||||
|
{ re: /[A-Z]/, label: 'Includes uppercase letter' },
|
||||||
|
{ re: /[$&+,:;=?@#|'<>.^*()%!-]/, label: 'Includes special symbol' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function getStrength(password: string) {
|
||||||
|
let multiplier = password.length > 5 ? 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 {
|
||||||
nextStep: ({ password }: { password: string }) => void;
|
nextStep: ({ password }: { password: string }) => void;
|
||||||
@@ -21,31 +60,69 @@ export const CreateAccountSecurityStep = ({
|
|||||||
validate: zodResolver(createAccountSecurityStepValidationSchema),
|
validate: zodResolver(createAccountSecurityStepValidationSchema),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { mutateAsync, isLoading } = api.password.generate.useMutation();
|
||||||
|
|
||||||
|
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}>
|
||||||
<Flex columnGap={10} align="start">
|
<Popover
|
||||||
<PasswordInput
|
opened={popoverOpened}
|
||||||
icon={<IconKey size="0.8rem" />}
|
position="bottom"
|
||||||
style={{
|
width="target"
|
||||||
flexGrow: 1,
|
transitionProps={{ transition: 'pop' }}
|
||||||
}}
|
>
|
||||||
label="Password"
|
<Popover.Target>
|
||||||
variant="filled"
|
<div
|
||||||
mb="md"
|
onFocusCapture={() => setPopoverOpened(true)}
|
||||||
withAsterisk
|
onBlurCapture={() => setPopoverOpened(false)}
|
||||||
{...form.getInputProps('password')}
|
>
|
||||||
/>
|
<Flex columnGap={10} align="start">
|
||||||
<Button
|
<PasswordInput
|
||||||
leftIcon={<IconDice size="1rem" />}
|
icon={<IconKey size="0.8rem" />}
|
||||||
onClick={() => {
|
style={{
|
||||||
form.setFieldValue('password', randomString());
|
flexGrow: 1,
|
||||||
}}
|
}}
|
||||||
variant="default"
|
label="Password"
|
||||||
mt="xl"
|
variant="filled"
|
||||||
>
|
mb="md"
|
||||||
Generate random
|
withAsterisk
|
||||||
</Button>
|
{...form.getInputProps('password')}
|
||||||
</Flex>
|
/>
|
||||||
|
<Button
|
||||||
|
leftIcon={<IconDice size="1rem" />}
|
||||||
|
onClick={async () => {
|
||||||
|
const randomPassword = await mutateAsync();
|
||||||
|
form.setFieldValue('password', randomPassword);
|
||||||
|
}}
|
||||||
|
loading={isLoading}
|
||||||
|
variant="default"
|
||||||
|
mt="xl"
|
||||||
|
>
|
||||||
|
Generate random
|
||||||
|
</Button>
|
||||||
|
</Flex>
|
||||||
|
</div>
|
||||||
|
</Popover.Target>
|
||||||
|
<Popover.Dropdown>
|
||||||
|
<Progress color={color} value={strength} size={5} mb="xs" />
|
||||||
|
<PasswordRequirement
|
||||||
|
label="Includes at least 6 characters"
|
||||||
|
meets={form.values.password.length > 5}
|
||||||
|
/>
|
||||||
|
{checks}
|
||||||
|
</Popover.Dropdown>
|
||||||
|
</Popover>
|
||||||
|
|
||||||
<Group position="apart" noWrap>
|
<Group position="apart" noWrap>
|
||||||
<Button leftIcon={<IconArrowLeft size="1rem" />} onClick={prevStep} variant="light" px="xl">
|
<Button leftIcon={<IconArrowLeft size="1rem" />} onClick={prevStep} variant="light" px="xl">
|
||||||
@@ -69,10 +146,19 @@ export const CreateAccountSecurityStep = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const randomString = () => {
|
const PasswordRequirement = ({ meets, label }: { meets: boolean; label: string }) => {
|
||||||
return window.crypto.getRandomValues(new BigUint64Array(1))[0].toString(36);
|
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}>{label}</Box>
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const createAccountSecurityStepValidationSchema = z.object({
|
export const createAccountSecurityStepValidationSchema = z.object({
|
||||||
password: z.string().min(8).max(100),
|
password: passwordSchema,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,22 +1,22 @@
|
|||||||
import { createTRPCRouter } from '~/server/api/trpc';
|
|
||||||
|
|
||||||
import { appRouter } from './routers/app';
|
import { appRouter } from './routers/app';
|
||||||
|
import { boardRouter } from './routers/board';
|
||||||
import { calendarRouter } from './routers/calendar';
|
import { calendarRouter } from './routers/calendar';
|
||||||
import { configRouter } from './routers/config';
|
import { configRouter } from './routers/config';
|
||||||
import { dashDotRouter } from './routers/dash-dot';
|
import { dashDotRouter } from './routers/dash-dot';
|
||||||
import { dnsHoleRouter } from './routers/dns-hole';
|
import { dnsHoleRouter } from './routers/dns-hole';
|
||||||
import { dockerRouter } from './routers/docker/router';
|
|
||||||
import { downloadRouter } from './routers/download';
|
import { downloadRouter } from './routers/download';
|
||||||
import { iconRouter } from './routers/icon';
|
import { iconRouter } from './routers/icon';
|
||||||
import { inviteRouter } from './routers/invite';
|
import { inviteRouter } from './routers/invite';
|
||||||
import { mediaRequestsRouter } from './routers/media-request';
|
import { mediaRequestsRouter } from './routers/media-request';
|
||||||
import { mediaServerRouter } from './routers/media-server';
|
import { mediaServerRouter } from './routers/media-server';
|
||||||
import { overseerrRouter } from './routers/overseerr';
|
import { overseerrRouter } from './routers/overseerr';
|
||||||
|
import { passwordRouter } from './routers/password';
|
||||||
import { rssRouter } from './routers/rss';
|
import { rssRouter } from './routers/rss';
|
||||||
import { usenetRouter } from './routers/usenet/router';
|
|
||||||
import { userRouter } from './routers/user';
|
import { userRouter } from './routers/user';
|
||||||
import { weatherRouter } from './routers/weather';
|
import { weatherRouter } from './routers/weather';
|
||||||
import { boardRouter } from './routers/board';
|
import { dockerRouter } from './routers/docker/router';
|
||||||
|
import { usenetRouter } from './routers/usenet/router';
|
||||||
|
import { createTRPCRouter } from '~/server/api/trpc';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This is the primary router for your server.
|
* This is the primary router for your server.
|
||||||
@@ -40,7 +40,8 @@ export const rootRouter = createTRPCRouter({
|
|||||||
calendar: calendarRouter,
|
calendar: calendarRouter,
|
||||||
weather: weatherRouter,
|
weather: weatherRouter,
|
||||||
invites: inviteRouter,
|
invites: inviteRouter,
|
||||||
boards: boardRouter
|
boards: boardRouter,
|
||||||
|
password: passwordRouter
|
||||||
});
|
});
|
||||||
|
|
||||||
// export type definition of API
|
// export type definition of API
|
||||||
|
|||||||
17
src/server/api/routers/password.ts
Normal file
17
src/server/api/routers/password.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { generate } from 'generate-password';
|
||||||
|
|
||||||
|
import { createTRPCRouter, publicProcedure } from "../trpc";
|
||||||
|
|
||||||
|
export const passwordRouter = createTRPCRouter({
|
||||||
|
generate: publicProcedure.mutation(() => {
|
||||||
|
return generate({
|
||||||
|
strict: true,
|
||||||
|
numbers: true,
|
||||||
|
lowercase: true,
|
||||||
|
uppercase: true,
|
||||||
|
symbols: true,
|
||||||
|
excludeSimilarCharacters: true,
|
||||||
|
length: 16
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
});
|
||||||
@@ -1,6 +1,16 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { CustomErrorParams } from '~/utils/i18n-zod-resolver';
|
import { CustomErrorParams } from '~/utils/i18n-zod-resolver';
|
||||||
|
|
||||||
|
export const passwordSchema = z
|
||||||
|
.string()
|
||||||
|
.min(8)
|
||||||
|
.max(100)
|
||||||
|
.refine((value) => /[0-9]/.test(value))
|
||||||
|
.refine((value) => /[a-z]/.test(value))
|
||||||
|
.refine((value) => /[A-Z]/.test(value))
|
||||||
|
.refine((value) => /[$&+,:;=?@#|'<>.^*()%!-]/.test(value));
|
||||||
|
|
||||||
export const signInSchema = z.object({
|
export const signInSchema = z.object({
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
password: z.string(),
|
password: z.string(),
|
||||||
@@ -22,7 +32,7 @@ export const signUpFormSchema = z
|
|||||||
export const createNewUserSchema = z.object({
|
export const createNewUserSchema = z.object({
|
||||||
username: z.string(),
|
username: z.string(),
|
||||||
email: z.string().email().optional(),
|
email: z.string().email().optional(),
|
||||||
password: z.string().min(8).max(100),
|
password: passwordSchema,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const colorSchemeParser = z
|
export const colorSchemeParser = z
|
||||||
|
|||||||
@@ -5446,6 +5446,13 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
"generate-password@npm:^1.7.0":
|
||||||
|
version: 1.7.0
|
||||||
|
resolution: "generate-password@npm:1.7.0"
|
||||||
|
checksum: c0d13e9a9c72d84adc4365a0c0dbd28463f2da1975b4ec83f34a126b95122551274755db641418e5aa11c8d94c1d216c8da8314f38e56e05378e1a43792f4614
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
"gensync@npm:^1.0.0-beta.2":
|
"gensync@npm:^1.0.0-beta.2":
|
||||||
version: 1.0.0-beta.2
|
version: 1.0.0-beta.2
|
||||||
resolution: "gensync@npm:1.0.0-beta.2"
|
resolution: "gensync@npm:1.0.0-beta.2"
|
||||||
@@ -5891,6 +5898,7 @@ __metadata:
|
|||||||
fily-publish-gridstack: ^0.0.13
|
fily-publish-gridstack: ^0.0.13
|
||||||
flag-icons: ^6.9.2
|
flag-icons: ^6.9.2
|
||||||
framer-motion: ^10.0.0
|
framer-motion: ^10.0.0
|
||||||
|
generate-password: ^1.7.0
|
||||||
happy-dom: ^10.0.0
|
happy-dom: ^10.0.0
|
||||||
html-entities: ^2.3.3
|
html-entities: ^2.3.3
|
||||||
i18next: ^22.5.1
|
i18next: ^22.5.1
|
||||||
|
|||||||
Reference in New Issue
Block a user