Compare commits
8 Commits
main
...
8d283064a0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8d283064a0 | ||
|
|
a6f4b993dd | ||
|
|
3d7eb850ec | ||
|
|
69d026afce | ||
|
|
87511398fb | ||
|
|
542de6c540 | ||
|
|
6d81eaba4b | ||
|
|
5b49398665 |
@@ -4,7 +4,7 @@ import { MOCK_TOKEN } from "shared/api";
|
||||
|
||||
export const MOCK_EMAIL = "test@test.com";
|
||||
export const MOCK_NEW_EMAIL = "new@test.com";
|
||||
export const MOCK_PASSWORD = "password";
|
||||
export const MOCK_PASSWORD = "100%GoodPassword";
|
||||
|
||||
export const MOCK_EXISTING_USER: User = {
|
||||
id: "1",
|
||||
|
||||
@@ -2,17 +2,7 @@ import { api as baseApi, useTokenStore } from "shared/api";
|
||||
|
||||
export const authHttpClient = baseApi.extend({
|
||||
hooks: {
|
||||
beforeRequest: [
|
||||
(request) => {
|
||||
const token = useTokenStore.getState().accessToken;
|
||||
|
||||
if (token) {
|
||||
request.headers.set("Authorization", `Bearer ${token}`);
|
||||
}
|
||||
|
||||
return request;
|
||||
},
|
||||
],
|
||||
beforeRequest: [],
|
||||
afterResponse: [
|
||||
async (request, options, response) => {
|
||||
if (response.status !== 401) {
|
||||
|
||||
@@ -1,2 +1,4 @@
|
||||
export { useAuth } from "./hooks/useAuth/useAuth";
|
||||
export { AuthGuard } from "./hocs/AuthGuard/AuthGuard";
|
||||
|
||||
export * from "./validators";
|
||||
|
||||
2
src/features/auth/lib/validators/index.ts
Normal file
2
src/features/auth/lib/validators/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { validateEmail } from "./validateEmail/validateEmail";
|
||||
export { validatePassword } from "./validatePassword/validatePassword";
|
||||
@@ -0,0 +1,87 @@
|
||||
import {
|
||||
MAX_DOMAIN_EXTENSION_LENGTH,
|
||||
MAX_DOMAIN_LABEL_LENGTH,
|
||||
MAX_DOMAIN_LENGTH,
|
||||
MAX_LOCAL_PART_LENGTH,
|
||||
validateEmail,
|
||||
} from "./validateEmail";
|
||||
|
||||
describe("validateEmail", () => {
|
||||
describe("Absence of some parts of an email", () => {
|
||||
it("should return false if there's no separator (@ symbol)", () => {
|
||||
expect(validateEmail("testexample.com")).toBe(false);
|
||||
});
|
||||
it("should return false for an email without a domain extension", () => {
|
||||
expect(validateEmail("test@example")).toBe(false);
|
||||
});
|
||||
it("should return false for an email without a domain", () => {
|
||||
expect(validateEmail("test@")).toBe(false);
|
||||
});
|
||||
it("should return false for an email with no local part", () => {
|
||||
expect(validateEmail("@example.com")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Excessive amount of some parts of an email", () => {
|
||||
it("should return false for an email with multiple separators", () => {
|
||||
expect(validateEmail("test@example@com")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Local part", () => {
|
||||
it("should return false for consecutive dots in local part (unquoted)", () => {
|
||||
expect(validateEmail("john..doe@example.com")).toBe(false);
|
||||
});
|
||||
it("should return true for consecutive dots inside a quoted string", () => {
|
||||
expect(validateEmail('"john..doe"@example.com')).toBe(true);
|
||||
});
|
||||
it("should return false for leading/trailing dots in local part", () => {
|
||||
expect(validateEmail(".john@example.com")).toBe(false);
|
||||
expect(validateEmail("john.@example.com")).toBe(false);
|
||||
});
|
||||
it("should return true for special characters in unquoted local part", () => {
|
||||
expect(validateEmail("!#$%&'*+-/=?^_`{|}~@example.com")).toBe(true);
|
||||
});
|
||||
it("should return true for escaped characters and spaces inside quotes", () => {
|
||||
expect(validateEmail('"John Doe"@example.com')).toBe(true);
|
||||
expect(validateEmail('"John\\"Doe"@example.com')).toBe(true);
|
||||
});
|
||||
it("should return false if the local part is too long", () => {
|
||||
const longLocalPart = "a".repeat(MAX_LOCAL_PART_LENGTH + 1);
|
||||
expect(validateEmail(longLocalPart + "@example.com")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Domain", () => {
|
||||
it("should return true for a domain starting with a digit", () => {
|
||||
expect(validateEmail("test@123example.com")).toBe(true);
|
||||
});
|
||||
it("should return false for a domain label ending with a hyphen", () => {
|
||||
expect(validateEmail("test@example-.com")).toBe(false);
|
||||
});
|
||||
it("should return false for a domain label starting with a hyphen", () => {
|
||||
expect(validateEmail("test@-example.com")).toBe(false);
|
||||
});
|
||||
it("should return false if a middle label exceeds maximum allowed characters", () => {
|
||||
const longLabel = "a".repeat(MAX_DOMAIN_LABEL_LENGTH + 1);
|
||||
expect(validateEmail(`test@${longLabel}.com`)).toBe(false);
|
||||
});
|
||||
it("should return true for complex subdomains", () => {
|
||||
expect(validateEmail("test@sub.sub-label.example.com")).toBe(true);
|
||||
});
|
||||
it("should return false if the domain is too long", () => {
|
||||
const longDomain = "a".repeat(MAX_DOMAIN_LENGTH + 1);
|
||||
expect(validateEmail(`test@${longDomain}.com`)).toBe(false);
|
||||
});
|
||||
it("should return false if the domain extension is too long", () => {
|
||||
const longExtension = "a".repeat(MAX_DOMAIN_EXTENSION_LENGTH + 1);
|
||||
expect(validateEmail(`test@example.${longExtension}`)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Valid emails", () => {
|
||||
it("should return true for a valid email", () => {
|
||||
expect(validateEmail("test@example.com")).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
export const MAX_LOCAL_PART_LENGTH = 63;
|
||||
export const MAX_DOMAIN_LENGTH = 255;
|
||||
export const MAX_DOMAIN_EXTENSION_LENGTH = 63;
|
||||
export const MAX_DOMAIN_LABEL_LENGTH = 63;
|
||||
const MAX_DOMAIN_LABEL_LENGTH_WITHOUT_START_AND_END_CHARS = Math.max(
|
||||
MAX_DOMAIN_LABEL_LENGTH - 2,
|
||||
0,
|
||||
);
|
||||
|
||||
// RFC 5322 compliant email regex
|
||||
export const EMAIL_REGEX = new RegExp(
|
||||
"^" +
|
||||
`(?=[^@]{1,${MAX_LOCAL_PART_LENGTH}}@)` +
|
||||
"(?:(?:" +
|
||||
'"(?:[^"\\\\]|\\\\.)+"' + // Quoted string support
|
||||
")|(?:" +
|
||||
"[a-zA-Z0-9!#$%&'*+\\-/=?^_`{|}~]+" +
|
||||
"(?:\\.[a-zA-Z0-9!#$%&'*+\\-/=?^_`{|}~]+)*" + // Dot restrictions
|
||||
"))" +
|
||||
"@" +
|
||||
`(?=.{1,${MAX_DOMAIN_LENGTH}}$)` +
|
||||
`(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,${MAX_DOMAIN_LABEL_LENGTH_WITHOUT_START_AND_END_CHARS}}[a-zA-Z0-9])?\\.)+` +
|
||||
`[a-zA-Z]{2,${MAX_DOMAIN_EXTENSION_LENGTH}}` +
|
||||
"$",
|
||||
);
|
||||
|
||||
/**
|
||||
* Validates an email address using a regular expression.
|
||||
* @param email The email address to validate.
|
||||
* @returns `true` if the email is valid, `false` otherwise.
|
||||
*/
|
||||
export function validateEmail(email: string): boolean {
|
||||
return EMAIL_REGEX.test(email);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import {
|
||||
MAX_PASSWORD_LENGTH,
|
||||
MIN_PASSWORD_LENGTH,
|
||||
validatePassword,
|
||||
} from "./validatePassword";
|
||||
|
||||
describe("validatePassword", () => {
|
||||
describe("Absence of necessary characters", () => {
|
||||
it("should return false when the password does not contain a lowercase letter", () => {
|
||||
expect(validatePassword("PASSWORD123!")).toBe(false);
|
||||
});
|
||||
it("should return false when the password does not contain an uppercase letter", () => {
|
||||
expect(validatePassword("password123!")).toBe(false);
|
||||
});
|
||||
it("should return false when the password does not contain a digit", () => {
|
||||
expect(validatePassword("Password!")).toBe(false);
|
||||
});
|
||||
it("should return false when the password does not contain a special character", () => {
|
||||
expect(validatePassword("Password123")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Length requirement", () => {
|
||||
const validChars = "aA1!";
|
||||
|
||||
it("should return false when the password is less than min length", () => {
|
||||
const shortPassword =
|
||||
validChars +
|
||||
"a".repeat(Math.max(0, MIN_PASSWORD_LENGTH - validChars.length - 1));
|
||||
|
||||
expect(validatePassword(shortPassword)).toBe(false);
|
||||
});
|
||||
it("should return true when the password is exactly min length", () => {
|
||||
const exactMinPassword =
|
||||
validChars +
|
||||
"a".repeat(Math.max(0, MIN_PASSWORD_LENGTH - validChars.length));
|
||||
|
||||
expect(validatePassword(exactMinPassword)).toBe(true);
|
||||
});
|
||||
it("should return false when the password is greater than max length", () => {
|
||||
const longPassword =
|
||||
validChars +
|
||||
"a".repeat(Math.max(0, MAX_PASSWORD_LENGTH - validChars.length + 1));
|
||||
|
||||
expect(validatePassword(longPassword)).toBe(false);
|
||||
});
|
||||
it("should return true when the password is exactly max length", () => {
|
||||
const exactMaxPassword =
|
||||
validChars +
|
||||
"a".repeat(Math.max(0, MAX_PASSWORD_LENGTH - validChars.length));
|
||||
|
||||
expect(validatePassword(exactMaxPassword)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Valid password", () => {
|
||||
it("should return true when the password is valid", () => {
|
||||
const validChars = "aA1!";
|
||||
const validPassword =
|
||||
validChars +
|
||||
"a".repeat(Math.max(0, MIN_PASSWORD_LENGTH - validChars.length));
|
||||
|
||||
expect(validatePassword(validPassword)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
export const MIN_PASSWORD_LENGTH = 11;
|
||||
export const MAX_PASSWORD_LENGTH = 63;
|
||||
|
||||
/**
|
||||
* The password regex used to validate passwords.
|
||||
* Uses a case-sensitive regex with at least one lowercase letter, one uppercase letter, one digit, and one special character.
|
||||
*/
|
||||
export const PASSWORD_REGEX = new RegExp(
|
||||
`^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)(?=.*[@$!%*?&])[A-Za-z\\d@$!%*?&]{${MIN_PASSWORD_LENGTH},${MAX_PASSWORD_LENGTH}}$`,
|
||||
);
|
||||
|
||||
/**
|
||||
* Validates a password against the password regex.
|
||||
* @param password The password to validate.
|
||||
* @returns `true` if the password is valid, `false` otherwise.
|
||||
*/
|
||||
export function validatePassword(password: string): boolean {
|
||||
return PASSWORD_REGEX.test(password);
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
export * from "./selectFormValid/selectFormValid";
|
||||
export * from "./selectAuthData/selectAuthData";
|
||||
export * from "./selectStatusIsLoading/selectStatusIsLoading";
|
||||
export * from "./selectEmail/selectEmail";
|
||||
export * from "./selectPassword/selectPassword";
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
import type { AuthStore } from "../../types/store";
|
||||
|
||||
export const selectEmail = (state: AuthStore) =>
|
||||
state.formData?.email?.value ?? "";
|
||||
@@ -0,0 +1,4 @@
|
||||
import type { AuthStore } from "../../types/store";
|
||||
|
||||
export const selectPassword = (state: AuthStore) =>
|
||||
state.formData?.password?.value ?? "";
|
||||
@@ -88,7 +88,7 @@ describe("authStore", () => {
|
||||
|
||||
it("should set error and update status if login fails", async () => {
|
||||
useAuthStore.getState().setEmail("wrong@test.com");
|
||||
useAuthStore.getState().setPassword("wrongPassword");
|
||||
useAuthStore.getState().setPassword("100%WrongPassword");
|
||||
|
||||
await useAuthStore.getState().login();
|
||||
|
||||
|
||||
@@ -2,8 +2,9 @@ import { create } from "zustand";
|
||||
import type { AuthStore, AuthStoreState } from "../../types/store";
|
||||
import { login, logout, register } from "../../../api";
|
||||
import { callApi } from "shared/utils";
|
||||
import { UNEXPECTED_ERROR_MESSAGE } from "shared/api";
|
||||
import { UNEXPECTED_ERROR_MESSAGE, useTokenStore } from "shared/api";
|
||||
import { selectAuthData, selectFormValid } from "../../selectors";
|
||||
import { validateEmail, validatePassword } from "../../../lib";
|
||||
|
||||
export const defaultStoreState: Readonly<AuthStoreState> = {
|
||||
formData: {
|
||||
@@ -15,14 +16,6 @@ export const defaultStoreState: Readonly<AuthStoreState> = {
|
||||
error: null,
|
||||
};
|
||||
|
||||
function validateEmail(email: string): boolean {
|
||||
return Boolean(email);
|
||||
}
|
||||
|
||||
function validatePassword(password: string): boolean {
|
||||
return Boolean(password);
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthStore>()((set, get) => ({
|
||||
...defaultStoreState,
|
||||
|
||||
@@ -75,6 +68,7 @@ export const useAuthStore = create<AuthStore>()((set, get) => ({
|
||||
user: responseData?.user,
|
||||
error: null,
|
||||
});
|
||||
useTokenStore.setState({ accessToken: responseData?.accessToken });
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
set({
|
||||
@@ -115,6 +109,7 @@ export const useAuthStore = create<AuthStore>()((set, get) => ({
|
||||
user: responseData?.user,
|
||||
error: null,
|
||||
});
|
||||
useTokenStore.setState({ accessToken: responseData?.accessToken });
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
set({
|
||||
@@ -145,7 +140,7 @@ export const useAuthStore = create<AuthStore>()((set, get) => ({
|
||||
error: null,
|
||||
});
|
||||
|
||||
// useTokenStore.setState({ accessToken: null });
|
||||
useTokenStore.setState({ accessToken: null });
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
set({ error: new Error(UNEXPECTED_ERROR_MESSAGE) });
|
||||
|
||||
@@ -1,16 +1,52 @@
|
||||
import {
|
||||
selectEmail,
|
||||
selectFormValid,
|
||||
selectPassword,
|
||||
selectStatusIsLoading,
|
||||
useAuthStore,
|
||||
} from "../../model";
|
||||
import type { SubmitEvent, ChangeEvent } from "react";
|
||||
import {
|
||||
type SubmitEvent,
|
||||
type ChangeEvent,
|
||||
type HTMLAttributes,
|
||||
type InputHTMLAttributes,
|
||||
type ButtonHTMLAttributes,
|
||||
cloneElement,
|
||||
type ReactElement,
|
||||
} from "react";
|
||||
|
||||
export type InputAttributes = InputHTMLAttributes<HTMLInputElement>;
|
||||
export type ButtonAttributes = ButtonHTMLAttributes<HTMLButtonElement>;
|
||||
|
||||
export interface Props {
|
||||
InputComponent?: ReactElement<InputAttributes>;
|
||||
ButtonComponent?: ReactElement<ButtonAttributes>;
|
||||
}
|
||||
|
||||
const DefaultInputComponent = ({
|
||||
value,
|
||||
onChange,
|
||||
["aria-label"]: ariaLabel,
|
||||
}: InputAttributes) => (
|
||||
<input
|
||||
type="email"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
aria-label={ariaLabel}
|
||||
/>
|
||||
);
|
||||
|
||||
const DefaultButtonComponent = ({ disabled }: ButtonAttributes) => (
|
||||
<button type="submit" disabled={disabled} />
|
||||
);
|
||||
|
||||
/**
|
||||
* Login form component
|
||||
*/
|
||||
export function LoginForm() {
|
||||
const { formData, setEmail, setPassword, login } = useAuthStore();
|
||||
|
||||
export function LoginForm({ InputComponent, ButtonComponent }: Props) {
|
||||
const { setEmail, setPassword, login } = useAuthStore();
|
||||
const email = useAuthStore(selectEmail);
|
||||
const password = useAuthStore(selectPassword);
|
||||
const formValid = useAuthStore(selectFormValid);
|
||||
const isLoading = useAuthStore(selectStatusIsLoading);
|
||||
|
||||
@@ -31,21 +67,47 @@ export function LoginForm() {
|
||||
|
||||
return (
|
||||
<form aria-label="Login form" onSubmit={handleSubmit}>
|
||||
<input
|
||||
type="email"
|
||||
aria-label="Email"
|
||||
value={formData?.email?.value ?? ""}
|
||||
onChange={handleEmailChange}
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
aria-label="Password"
|
||||
value={formData?.password?.value ?? ""}
|
||||
onChange={handlePasswordChange}
|
||||
/>
|
||||
<button type="submit" disabled={disabled}>
|
||||
Login
|
||||
</button>
|
||||
{InputComponent ? (
|
||||
cloneElement(InputComponent, {
|
||||
type: "email",
|
||||
value: email,
|
||||
onChange: handleEmailChange,
|
||||
"aria-label": "Email",
|
||||
})
|
||||
) : (
|
||||
<DefaultInputComponent
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={handleEmailChange}
|
||||
aria-label="Email"
|
||||
/>
|
||||
)}
|
||||
{InputComponent ? (
|
||||
cloneElement(InputComponent, {
|
||||
type: "password",
|
||||
value: password,
|
||||
onChange: handlePasswordChange,
|
||||
"aria-label": "Password",
|
||||
})
|
||||
) : (
|
||||
<DefaultInputComponent
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={handlePasswordChange}
|
||||
aria-label="Password"
|
||||
/>
|
||||
)}
|
||||
{ButtonComponent ? (
|
||||
cloneElement(ButtonComponent, {
|
||||
type: "submit",
|
||||
disabled: disabled,
|
||||
children: "Login",
|
||||
})
|
||||
) : (
|
||||
<DefaultButtonComponent type="submit" disabled={disabled}>
|
||||
Login
|
||||
</DefaultButtonComponent>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user