feat: add password requirements (#988)

* feat: add password requirements

* fix: format issue

* fix: unexpected empty string in component jsx

* test: adjust unit test passwords
This commit is contained in:
Meier Lukas
2024-08-19 21:11:36 +02:00
committed by GitHub
parent 058518710e
commit 2d155fa0c4
14 changed files with 198 additions and 37 deletions

View File

@@ -7,6 +7,7 @@ import { clientApi } from "@homarr/api/client";
import { useZodForm } from "@homarr/form";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
import { CustomPasswordInput } from "@homarr/ui";
import type { z } from "@homarr/validation";
import { validation } from "@homarr/validation";
@@ -64,7 +65,8 @@ export const RegistrationForm = ({ invite }: RegistrationFormProps) => {
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack gap="lg">
<TextInput label={t("field.username.label")} autoComplete="off" {...form.getInputProps("username")} />
<PasswordInput
<CustomPasswordInput
withPasswordRequirements
label={t("field.password.label")}
autoComplete="new-password"
{...form.getInputProps("password")}

View File

@@ -7,6 +7,7 @@ import { clientApi } from "@homarr/api/client";
import { useZodForm } from "@homarr/form";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
import { CustomPasswordInput } from "@homarr/ui";
import type { z } from "@homarr/validation";
import { validation } from "@homarr/validation";
@@ -50,7 +51,11 @@ export const InitUserForm = () => {
>
<Stack gap="lg">
<TextInput label={t("field.username.label")} {...form.getInputProps("username")} />
<PasswordInput label={t("field.password.label")} {...form.getInputProps("password")} />
<CustomPasswordInput
withPasswordRequirements
label={t("field.password.label")}
{...form.getInputProps("password")}
/>
<PasswordInput label={t("field.passwordConfirm.label")} {...form.getInputProps("confirmPassword")} />
<Button type="submit" fullWidth loading={isPending}>
{t("action.create")}

View File

@@ -8,6 +8,7 @@ import { useSession } from "@homarr/auth/client";
import { useZodForm } from "@homarr/form";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import { CustomPasswordInput } from "@homarr/ui";
import { validation } from "@homarr/validation";
import { revalidatePathActionAsync } from "~/app/revalidatePathAction";
@@ -71,7 +72,12 @@ export const ChangePasswordForm = ({ user }: ChangePasswordFormProps) => {
/>
)}
<PasswordInput withAsterisk label={t("user.field.password.label")} {...form.getInputProps("password")} />
<CustomPasswordInput
withPasswordRequirements
withAsterisk
label={t("user.field.password.label")}
{...form.getInputProps("password")}
/>
<PasswordInput
withAsterisk

View File

@@ -8,7 +8,7 @@ import { clientApi } from "@homarr/api/client";
import { useZodForm } from "@homarr/form";
import { showErrorNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
import { UserAvatar } from "@homarr/ui";
import { CustomPasswordInput, UserAvatar } from "@homarr/ui";
import { validation, z } from "@homarr/validation";
import { createCustomErrorParams } from "@homarr/validation/form";
@@ -124,7 +124,8 @@ export const UserCreateStepperComponent = () => {
<form>
<Card p="xl">
<Stack gap="md">
<PasswordInput
<CustomPasswordInput
withPasswordRequirements
label={tUserField("password.label")}
variant="filled"
withAsterisk

View File

@@ -39,8 +39,8 @@ describe("initUser should initialize the first user", () => {
const actAsync = async () =>
await caller.initUser({
username: "test",
password: "12345678",
confirmPassword: "12345678",
password: "123ABCdef+/-",
confirmPassword: "123ABCdef+/-",
});
await expect(actAsync()).rejects.toThrow("User already exists");
@@ -55,8 +55,8 @@ describe("initUser should initialize the first user", () => {
await caller.initUser({
username: "test",
password: "12345678",
confirmPassword: "12345678",
password: "123ABCdef+/-",
confirmPassword: "123ABCdef+/-",
});
const user = await db.query.users.findFirst({
@@ -78,14 +78,20 @@ describe("initUser should initialize the first user", () => {
const actAsync = async () =>
await caller.initUser({
username: "test",
password: "12345678",
confirmPassword: "12345679",
password: "123ABCdef+/-",
confirmPassword: "456ABCdef+/-",
});
await expect(actAsync()).rejects.toThrow("passwordsDoNotMatch");
});
it("should not create a user if the password is too short", async () => {
it.each([
["aB2%"], // too short
["abc123DEF"], // does not contain special characters
["abcDEFghi+"], // does not contain numbers
["ABC123+/-"], // does not contain lowercase
["abc123+/-"], // does not contain uppercase
])("should throw error that password requirements do not match for '%s' as password", async (password) => {
const db = createDb();
const caller = userRouter.createCaller({
db,
@@ -95,11 +101,11 @@ describe("initUser should initialize the first user", () => {
const actAsync = async () =>
await caller.initUser({
username: "test",
password: "1234567",
confirmPassword: "1234567",
password,
confirmPassword: password,
});
await expect(actAsync()).rejects.toThrow("too_small");
await expect(actAsync()).rejects.toThrow("passwordRequirements");
});
});
@@ -133,8 +139,8 @@ describe("register should create a user with valid invitation", () => {
inviteId,
token: inviteToken,
username: "test",
password: "12345678",
confirmPassword: "12345678",
password: "123ABCdef+/-",
confirmPassword: "123ABCdef+/-",
});
// Assert
@@ -189,8 +195,8 @@ describe("register should create a user with valid invitation", () => {
inviteId,
token: inviteToken,
username: "test",
password: "12345678",
confirmPassword: "12345678",
password: "123ABCdef+/-",
confirmPassword: "123ABCdef+/-",
...partialInput,
});

View File

@@ -30,6 +30,13 @@ export default {
},
password: {
label: "Password",
requirement: {
length: "Includes at least 8 characters",
lowercase: "Includes lowercase letter",
uppercase: "Includes uppercase letter",
number: "Includes number",
special: "Includes special symbol",
},
},
passwordConfirm: {
label: "Confirm password",
@@ -631,6 +638,7 @@ export default {
},
custom: {
passwordsDoNotMatch: "Passwords do not match",
passwordRequirements: "Password does not meet the requirements",
boardAlreadyExists: "A board with this name already exists",
},
},

View File

@@ -24,6 +24,7 @@
},
"dependencies": {
"@homarr/log": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0",
"@homarr/common": "workspace:^0.1.0",
"@homarr/translation": "workspace:^0.1.0",
"@mantine/core": "^7.12.1",

View File

@@ -7,3 +7,4 @@ export { TablePagination } from "./table-pagination";
export { TextMultiSelect } from "./text-multi-select";
export { UserAvatar } from "./user-avatar";
export { UserAvatarGroup } from "./user-avatar-group";
export { CustomPasswordInput } from "./password-input/password-input";

View File

@@ -0,0 +1,35 @@
"use client";
import type { ChangeEvent } from "react";
import { useState } from "react";
import { PasswordInput } from "@mantine/core";
import type { PasswordInputProps } from "@mantine/core";
import { PasswordRequirementsPopover } from "./password-requirements-popover";
interface CustomPasswordInputProps extends PasswordInputProps {
withPasswordRequirements?: boolean;
}
export const CustomPasswordInput = ({ withPasswordRequirements, ...props }: CustomPasswordInputProps) => {
if (withPasswordRequirements) {
return <WithPasswordRequirements {...props} />;
}
return <PasswordInput {...props} />;
};
const WithPasswordRequirements = (props: PasswordInputProps) => {
const [value, setValue] = useState("");
const onChange = (event: ChangeEvent<HTMLInputElement>) => {
setValue(event.currentTarget.value);
props.onChange?.(event);
};
return (
<PasswordRequirementsPopover password={value}>
<PasswordInput {...props} onChange={onChange} />
</PasswordRequirementsPopover>
);
};

View File

@@ -0,0 +1,17 @@
import { rem, Text } from "@mantine/core";
import { IconCheck, IconX } from "@tabler/icons-react";
export function PasswordRequirement({ meets, label }: { meets: boolean; label: string }) {
return (
<Text c={meets ? "teal" : "red"} display="flex" style={{ alignItems: "center" }} size="sm">
{meets ? (
<IconCheck style={{ width: rem(14), height: rem(14) }} />
) : (
<IconX style={{ width: rem(14), height: rem(14) }} />
)}
<Text span ml={10}>
{label}
</Text>
</Text>
);
}

View File

@@ -0,0 +1,49 @@
import type { PropsWithChildren } from "react";
import { useState } from "react";
import { Popover, Progress } from "@mantine/core";
import { useScopedI18n } from "@homarr/translation/client";
import { passwordRequirements } from "@homarr/validation";
import { PasswordRequirement } from "./password-requirement";
export const PasswordRequirementsPopover = ({ password, children }: PropsWithChildren<{ password: string }>) => {
const requirements = useRequirements();
const strength = useStrength(password);
const [popoverOpened, setPopoverOpened] = useState(false);
const checks = (
<>
{requirements.map((requirement) => (
<PasswordRequirement key={requirement.label} label={requirement.label} meets={requirement.check(password)} />
))}
</>
);
const color = strength === 100 ? "teal" : strength > 50 ? "yellow" : "red";
return (
<Popover opened={popoverOpened} position="bottom" width="target" transitionProps={{ transition: "pop" }}>
<Popover.Target>
<div onFocusCapture={() => setPopoverOpened(true)} onBlurCapture={() => setPopoverOpened(false)}>
{children}
</div>
</Popover.Target>
<Popover.Dropdown>
<Progress color={color} value={strength} size={5} mb="xs" />
{checks}
</Popover.Dropdown>
</Popover>
);
};
const useRequirements = () => {
const t = useScopedI18n("user.field.password.requirement");
return passwordRequirements.map(({ check, value }) => ({ check, label: t(value) }));
};
function useStrength(password: string) {
const requirements = useRequirements();
return (100 / requirements.length) * requirements.filter(({ check }) => check(password)).length;
}

View File

@@ -25,3 +25,4 @@ export {
type BoardItemIntegration,
type BoardItemAdvancedOptions,
} from "./shared";
export { passwordRequirements } from "./user";

View File

@@ -1,9 +1,35 @@
import { z } from "zod";
import type { TranslationObject } from "@homarr/translation";
import { createCustomErrorParams } from "./form/i18n";
const usernameSchema = z.string().min(3).max(255);
const passwordSchema = z.string().min(8).max(255);
const regexCheck = (regex: RegExp) => (value: string) => regex.test(value);
export const passwordRequirements = [
{ check: (value) => value.length >= 8, value: "length" },
{ check: regexCheck(/[a-z]/), value: "lowercase" },
{ check: regexCheck(/[A-Z]/), value: "uppercase" },
{ check: regexCheck(/\d/), value: "number" },
{ check: regexCheck(/[$&+,:;=?@#|'<>.^*()%!-]/), value: "special" },
] satisfies {
check: (value: string) => boolean;
value: keyof TranslationObject["user"]["field"]["password"]["requirement"];
}[];
const passwordSchema = z
.string()
.min(8)
.max(255)
.refine(
(value) => {
return passwordRequirements.every((requirement) => requirement.check(value));
},
{
params: createCustomErrorParams("passwordRequirements"),
},
);
const confirmPasswordRefine = [
(data: { password: string; confirmPassword: string }) => data.password === data.confirmPassword,

37
pnpm-lock.yaml generated
View File

@@ -16,10 +16,10 @@ importers:
version: 2.0.14(@types/node@20.16.1)(typescript@5.5.4)
'@vitejs/plugin-react':
specifier: ^4.3.1
version: 4.3.1(vite@5.2.6(@types/node@20.16.1)(sass@1.77.8)(sugarss@4.0.1(postcss@8.4.38))(terser@5.31.0))
version: 4.3.1(vite@5.2.6(@types/node@20.16.1)(sass@1.77.8)(sugarss@4.0.1)(terser@5.31.0))
'@vitest/coverage-v8':
specifier: ^2.0.5
version: 2.0.5(vitest@2.0.5(@types/node@20.16.1)(@vitest/ui@2.0.5)(jsdom@24.1.1)(sass@1.77.8)(sugarss@4.0.1(postcss@8.4.38))(terser@5.31.0))
version: 2.0.5(vitest@2.0.5(@types/node@20.16.1)(@vitest/ui@2.0.5)(jsdom@24.1.1)(sass@1.77.8)(sugarss@4.0.1)(terser@5.31.0))
'@vitest/ui':
specifier: ^2.0.5
version: 2.0.5(vitest@2.0.5)
@@ -43,10 +43,10 @@ importers:
version: 5.5.4
vite-tsconfig-paths:
specifier: ^5.0.1
version: 5.0.1(typescript@5.5.4)(vite@5.2.6(@types/node@20.16.1)(sass@1.77.8)(sugarss@4.0.1(postcss@8.4.38))(terser@5.31.0))
version: 5.0.1(typescript@5.5.4)(vite@5.2.6(@types/node@20.16.1)(sass@1.77.8)(sugarss@4.0.1)(terser@5.31.0))
vitest:
specifier: ^2.0.5
version: 2.0.5(@types/node@20.16.1)(@vitest/ui@2.0.5)(jsdom@24.1.1)(sass@1.77.8)(sugarss@4.0.1(postcss@8.4.38))(terser@5.31.0)
version: 2.0.5(@types/node@20.16.1)(@vitest/ui@2.0.5)(jsdom@24.1.1)(sass@1.77.8)(sugarss@4.0.1)(terser@5.31.0)
apps/nextjs:
dependencies:
@@ -1223,6 +1223,9 @@ importers:
'@homarr/translation':
specifier: workspace:^0.1.0
version: link:../translation
'@homarr/validation':
specifier: workspace:^0.1.0
version: link:../validation
'@mantine/core':
specifier: ^7.12.1
version: 7.12.1(@mantine/hooks@7.12.1(react@18.3.1))(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@@ -8066,18 +8069,18 @@ snapshots:
global: 4.4.0
is-function: 1.0.2
'@vitejs/plugin-react@4.3.1(vite@5.2.6(@types/node@20.16.1)(sass@1.77.8)(sugarss@4.0.1(postcss@8.4.38))(terser@5.31.0))':
'@vitejs/plugin-react@4.3.1(vite@5.2.6(@types/node@20.16.1)(sass@1.77.8)(sugarss@4.0.1)(terser@5.31.0))':
dependencies:
'@babel/core': 7.24.6
'@babel/plugin-transform-react-jsx-self': 7.24.6(@babel/core@7.24.6)
'@babel/plugin-transform-react-jsx-source': 7.24.6(@babel/core@7.24.6)
'@types/babel__core': 7.20.5
react-refresh: 0.14.2
vite: 5.2.6(@types/node@20.16.1)(sass@1.77.8)(sugarss@4.0.1(postcss@8.4.38))(terser@5.31.0)
vite: 5.2.6(@types/node@20.16.1)(sass@1.77.8)(sugarss@4.0.1)(terser@5.31.0)
transitivePeerDependencies:
- supports-color
'@vitest/coverage-v8@2.0.5(vitest@2.0.5(@types/node@20.16.1)(@vitest/ui@2.0.5)(jsdom@24.1.1)(sass@1.77.8)(sugarss@4.0.1(postcss@8.4.38))(terser@5.31.0))':
'@vitest/coverage-v8@2.0.5(vitest@2.0.5(@types/node@20.16.1)(@vitest/ui@2.0.5)(jsdom@24.1.1)(sass@1.77.8)(sugarss@4.0.1)(terser@5.31.0))':
dependencies:
'@ampproject/remapping': 2.3.0
'@bcoe/v8-coverage': 0.2.3
@@ -8091,7 +8094,7 @@ snapshots:
std-env: 3.7.0
test-exclude: 7.0.1
tinyrainbow: 1.2.0
vitest: 2.0.5(@types/node@20.16.1)(@vitest/ui@2.0.5)(jsdom@24.1.1)(sass@1.77.8)(sugarss@4.0.1(postcss@8.4.38))(terser@5.31.0)
vitest: 2.0.5(@types/node@20.16.1)(@vitest/ui@2.0.5)(jsdom@24.1.1)(sass@1.77.8)(sugarss@4.0.1)(terser@5.31.0)
transitivePeerDependencies:
- supports-color
@@ -8130,7 +8133,7 @@ snapshots:
pathe: 1.1.2
sirv: 2.0.4
tinyrainbow: 1.2.0
vitest: 2.0.5(@types/node@20.16.1)(@vitest/ui@2.0.5)(jsdom@24.1.1)(sass@1.77.8)(sugarss@4.0.1(postcss@8.4.38))(terser@5.31.0)
vitest: 2.0.5(@types/node@20.16.1)(@vitest/ui@2.0.5)(jsdom@24.1.1)(sass@1.77.8)(sugarss@4.0.1)(terser@5.31.0)
'@vitest/utils@2.0.5':
dependencies:
@@ -12110,13 +12113,13 @@ snapshots:
dependencies:
global: 4.4.0
vite-node@2.0.5(@types/node@20.16.1)(sass@1.77.8)(sugarss@4.0.1(postcss@8.4.38))(terser@5.31.0):
vite-node@2.0.5(@types/node@20.16.1)(sass@1.77.8)(sugarss@4.0.1)(terser@5.31.0):
dependencies:
cac: 6.7.14
debug: 4.3.5
pathe: 1.1.2
tinyrainbow: 1.2.0
vite: 5.2.6(@types/node@20.16.1)(sass@1.77.8)(sugarss@4.0.1(postcss@8.4.38))(terser@5.31.0)
vite: 5.2.6(@types/node@20.16.1)(sass@1.77.8)(sugarss@4.0.1)(terser@5.31.0)
transitivePeerDependencies:
- '@types/node'
- less
@@ -12127,18 +12130,18 @@ snapshots:
- supports-color
- terser
vite-tsconfig-paths@5.0.1(typescript@5.5.4)(vite@5.2.6(@types/node@20.16.1)(sass@1.77.8)(sugarss@4.0.1(postcss@8.4.38))(terser@5.31.0)):
vite-tsconfig-paths@5.0.1(typescript@5.5.4)(vite@5.2.6(@types/node@20.16.1)(sass@1.77.8)(sugarss@4.0.1)(terser@5.31.0)):
dependencies:
debug: 4.3.5
globrex: 0.1.2
tsconfck: 3.0.3(typescript@5.5.4)
optionalDependencies:
vite: 5.2.6(@types/node@20.16.1)(sass@1.77.8)(sugarss@4.0.1(postcss@8.4.38))(terser@5.31.0)
vite: 5.2.6(@types/node@20.16.1)(sass@1.77.8)(sugarss@4.0.1)(terser@5.31.0)
transitivePeerDependencies:
- supports-color
- typescript
vite@5.2.6(@types/node@20.16.1)(sass@1.77.8)(sugarss@4.0.1(postcss@8.4.38))(terser@5.31.0):
vite@5.2.6(@types/node@20.16.1)(sass@1.77.8)(sugarss@4.0.1)(terser@5.31.0):
dependencies:
esbuild: 0.20.2
postcss: 8.4.38
@@ -12150,7 +12153,7 @@ snapshots:
sugarss: 4.0.1(postcss@8.4.38)
terser: 5.31.0
vitest@2.0.5(@types/node@20.16.1)(@vitest/ui@2.0.5)(jsdom@24.1.1)(sass@1.77.8)(sugarss@4.0.1(postcss@8.4.38))(terser@5.31.0):
vitest@2.0.5(@types/node@20.16.1)(@vitest/ui@2.0.5)(jsdom@24.1.1)(sass@1.77.8)(sugarss@4.0.1)(terser@5.31.0):
dependencies:
'@ampproject/remapping': 2.3.0
'@vitest/expect': 2.0.5
@@ -12168,8 +12171,8 @@ snapshots:
tinybench: 2.8.0
tinypool: 1.0.0
tinyrainbow: 1.2.0
vite: 5.2.6(@types/node@20.16.1)(sass@1.77.8)(sugarss@4.0.1(postcss@8.4.38))(terser@5.31.0)
vite-node: 2.0.5(@types/node@20.16.1)(sass@1.77.8)(sugarss@4.0.1(postcss@8.4.38))(terser@5.31.0)
vite: 5.2.6(@types/node@20.16.1)(sass@1.77.8)(sugarss@4.0.1)(terser@5.31.0)
vite-node: 2.0.5(@types/node@20.16.1)(sass@1.77.8)(sugarss@4.0.1)(terser@5.31.0)
why-is-node-running: 2.3.0
optionalDependencies:
'@types/node': 20.16.1