Skip to content

Commit

Permalink
feat: add password requirements (#988)
Browse files Browse the repository at this point in the history
* feat: add password requirements

* fix: format issue

* fix: unexpected empty string in component jsx

* test: adjust unit test passwords
  • Loading branch information
Meierschlumpf authored Aug 19, 2024
1 parent 0585187 commit 2d155fa
Show file tree
Hide file tree
Showing 14 changed files with 198 additions and 37 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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")}
Expand Down
7 changes: 6 additions & 1 deletion apps/nextjs/src/app/[locale]/init/user/_init-user-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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")}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -124,7 +124,8 @@ export const UserCreateStepperComponent = () => {
<form>
<Card p="xl">
<Stack gap="md">
<PasswordInput
<CustomPasswordInput
withPasswordRequirements
label={tUserField("password.label")}
variant="filled"
withAsterisk
Expand Down
34 changes: 20 additions & 14 deletions packages/api/src/router/test/user.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand All @@ -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({
Expand All @@ -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,
Expand All @@ -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");
});
});

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
});

Expand Down
8 changes: 8 additions & 0 deletions packages/translation/src/lang/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
},
},
Expand Down
1 change: 1 addition & 0 deletions packages/ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions packages/ui/src/components/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
35 changes: 35 additions & 0 deletions packages/ui/src/components/password-input/password-input.tsx
Original file line number Diff line number Diff line change
@@ -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>
);
};
17 changes: 17 additions & 0 deletions packages/ui/src/components/password-input/password-requirement.tsx
Original file line number Diff line number Diff line change
@@ -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>
);
}
Original file line number Diff line number Diff line change
@@ -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;
}
1 change: 1 addition & 0 deletions packages/validation/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,4 @@ export {
type BoardItemIntegration,
type BoardItemAdvancedOptions,
} from "./shared";
export { passwordRequirements } from "./user";
28 changes: 27 additions & 1 deletion packages/validation/src/user.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
Loading

0 comments on commit 2d155fa

Please sign in to comment.