diff --git a/frontend/.oxlintrc.json b/frontend/.oxlintrc.json index 6fa991d..7097c79 100644 --- a/frontend/.oxlintrc.json +++ b/frontend/.oxlintrc.json @@ -3,6 +3,6 @@ "plugins": ["react", "typescript", "oxc"], "rules": { "react/rules-of-hooks": "error", - "react/only-export-components": ["warn", { "allowConstantExport": true }] + "react/only-export-components": "off" } } diff --git a/frontend/.storybook/preview.tsx b/frontend/.storybook/preview.tsx index e4fe7e4..2bd31f5 100644 --- a/frontend/.storybook/preview.tsx +++ b/frontend/.storybook/preview.tsx @@ -2,6 +2,7 @@ import { useEffect } from "react"; import type { Preview } from "@storybook/react-vite"; import { initialize, mswLoader } from "msw-storybook-addon"; import { I18nextProvider } from "react-i18next"; +import { HashRouter } from "react-router-dom"; import "../src/index.css"; import { handlers } from "../src/mocks/handlers"; import { loadBrandingConfig } from "../src/branding/brandingConfig"; @@ -49,9 +50,11 @@ const preview: Preview = { }, [i18n, context.globals.locale]); return ( - - - + + + + + ); }, ], diff --git a/frontend/package.json b/frontend/package.json index f250099..8b37076 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -31,6 +31,8 @@ "react": "^19.2.7", "react-dom": "^19.2.7", "react-i18next": "^17.0.10", + "react-icons": "^5.7.0", + "react-router-dom": "^7.18.1", "tailwind-merge": "^3.6.0", "tailwindcss": "^4.3.3", "tw-animate-css": "^1.4.0" diff --git a/frontend/public/brand-graphic.svg b/frontend/public/brand-graphic.svg new file mode 100644 index 0000000..892e5ec --- /dev/null +++ b/frontend/public/brand-graphic.svg @@ -0,0 +1,20 @@ + + + + + + + + + + diff --git a/frontend/src/_test_utilities/test-utils.tsx b/frontend/src/_test_utilities/test-utils.tsx new file mode 100644 index 0000000..89ee815 --- /dev/null +++ b/frontend/src/_test_utilities/test-utils.tsx @@ -0,0 +1,16 @@ +import type { ReactElement, ReactNode } from "react"; +import { render as rtlRender, type RenderOptions } from "@testing-library/react"; +import { HashRouter } from "react-router-dom"; + +// Wraps components under test in app-wide providers +export const AllTheProviders = ({ children }: { children: ReactNode }) => { + return {children}; +}; + +function render(ui: ReactElement, options?: Omit) { + return rtlRender(ui, { wrapper: AllTheProviders, ...options }); +} + +export * from "@testing-library/react"; +export * from "@testing-library/user-event"; +export { render }; diff --git a/frontend/src/App.tsx b/frontend/src/app/Layout.tsx similarity index 70% rename from frontend/src/App.tsx rename to frontend/src/app/Layout.tsx index fc55e74..5706190 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/app/Layout.tsx @@ -1,16 +1,14 @@ +import { Outlet } from "react-router-dom"; import { AppSidebar } from "@/components/app-sidebar"; import { SidebarProvider, SidebarInset } from "@/components/ui/sidebar"; -import { HomePage } from "@/pages/HomePage"; -function App() { +export const Layout = () => { return ( - + ); -} - -export default App; +}; diff --git a/frontend/src/app/ProtectedRoute/ProtectedRoute.tsx b/frontend/src/app/ProtectedRoute/ProtectedRoute.tsx new file mode 100644 index 0000000..2ff4e0a --- /dev/null +++ b/frontend/src/app/ProtectedRoute/ProtectedRoute.tsx @@ -0,0 +1,11 @@ +import { type ReactNode } from "react"; + +interface ProtectedRouteProps { + children: ReactNode; +} + +const ProtectedRoute = ({ children }: ProtectedRouteProps) => { + return <>{children}; +}; + +export default ProtectedRoute; diff --git a/frontend/src/app/index.tsx b/frontend/src/app/index.tsx new file mode 100644 index 0000000..af01da3 --- /dev/null +++ b/frontend/src/app/index.tsx @@ -0,0 +1,48 @@ +import { createHashRouter, Navigate, RouterProvider } from "react-router-dom"; +import ProtectedRoute from "@/app/ProtectedRoute/ProtectedRoute"; +import { routerPaths } from "@/app/routerPaths"; +import { Layout } from "@/app/Layout"; +import { HomePage } from "@/pages/HomePage"; +import { Login } from "@/pages/Login/Login"; +import { Register } from "@/pages/Register/Register"; + +const router = createHashRouter([ + { + path: routerPaths.LOGIN, + element: ( + + + + ), + }, + { + path: routerPaths.REGISTER, + element: ( + + + + ), + }, + { + path: routerPaths.ROOT, + element: , + children: [ + { + index: true, + element: ( + + + + ), + }, + ], + }, + { + path: "*", + element: , + }, +]); + +export default function App() { + return ; +} diff --git a/frontend/src/app/routerPaths.ts b/frontend/src/app/routerPaths.ts new file mode 100644 index 0000000..d9a6a92 --- /dev/null +++ b/frontend/src/app/routerPaths.ts @@ -0,0 +1,5 @@ +export const routerPaths = { + ROOT: "/", + LOGIN: "/login", + REGISTER: "/register", +}; diff --git a/frontend/src/auth/auth.types.ts b/frontend/src/auth/auth.types.ts new file mode 100644 index 0000000..8030c67 --- /dev/null +++ b/frontend/src/auth/auth.types.ts @@ -0,0 +1,11 @@ +export interface LoginRequest { + email: string; + password: string; +} + +export interface RegisterRequest { + fullName: string; + organization: string; + email: string; + password: string; +} diff --git a/frontend/src/auth/components/AuthLayout/AuthLayout.stories.tsx b/frontend/src/auth/components/AuthLayout/AuthLayout.stories.tsx new file mode 100644 index 0000000..00e73d1 --- /dev/null +++ b/frontend/src/auth/components/AuthLayout/AuthLayout.stories.tsx @@ -0,0 +1,33 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { AuthLayout } from "./AuthLayout"; + +const meta = { + component: AuthLayout, + tags: ["autodocs"], + parameters: { layout: "fullscreen" }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + children: ( +
+
+

Form panel

+

Each page renders its content in this slot.

+
+
+ Field +
+
+ Field +
+
+ Action +
+
+ ), + }, +}; diff --git a/frontend/src/auth/components/AuthLayout/AuthLayout.test.tsx b/frontend/src/auth/components/AuthLayout/AuthLayout.test.tsx new file mode 100644 index 0000000..42e1aaa --- /dev/null +++ b/frontend/src/auth/components/AuthLayout/AuthLayout.test.tsx @@ -0,0 +1,50 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { AuthLayout, DATA_TEST_ID } from "./AuthLayout"; + +describe("AuthLayout", () => { + describe("Render tests", () => { + it("should render the brand panel copy from the real translations", () => { + // GIVEN the layout with slot content + // WHEN it is rendered + render( + +
form slot
+
+ ); + + // THEN the brand headline, subcopy, and footer are present + expect(screen.getByText("The dashboard behind every deployment.")).toBeInTheDocument(); + expect(screen.getByText(/how engaged they are/)).toBeInTheDocument(); + expect(screen.getByText("Open-source digital public infrastructure for jobs.")).toBeInTheDocument(); + }); + + it("should render the provided form content in the form panel", () => { + // GIVEN slot content + // WHEN rendered + render( + + + + ); + + // THEN the slot content appears inside the form panel + const formPanel = screen.getByTestId(DATA_TEST_ID.FORM_PANEL); + expect(formPanel).toContainElement(screen.getByRole("button", { name: "Sign in" })); + }); + + it("should render both the brand and form panels", () => { + // GIVEN the layout + // WHEN rendered + render( + +
form
+
+ ); + + // THEN both structural panels exist + expect(screen.getByTestId(DATA_TEST_ID.BRAND_PANEL)).toBeInTheDocument(); + expect(screen.getByTestId(DATA_TEST_ID.FORM_PANEL)).toBeInTheDocument(); + }); + }); +}); diff --git a/frontend/src/auth/components/AuthLayout/AuthLayout.tsx b/frontend/src/auth/components/AuthLayout/AuthLayout.tsx new file mode 100644 index 0000000..43e85bf --- /dev/null +++ b/frontend/src/auth/components/AuthLayout/AuthLayout.tsx @@ -0,0 +1,54 @@ +import type { ReactNode } from "react"; +import { useTranslation } from "react-i18next"; +import { getAppName, getLogoInverseUrl } from "@/branding/brandingConfig"; + +const uniqueId = "2dce0a1d-275e-4a46-952a-d9193ddd1c15"; + +export const DATA_TEST_ID = { + CONTAINER: `auth-layout-container-${uniqueId}`, + BRAND_PANEL: `auth-layout-brand-panel-${uniqueId}`, + FORM_PANEL: `auth-layout-form-panel-${uniqueId}`, +}; + +export interface AuthLayoutProps { + children: ReactNode; +} + +export function AuthLayout({ children }: Readonly) { + const { t } = useTranslation(); + + return ( +
+
+ {getAppName()} + +
+

{t("auth.brand.eyebrow")}

+

+ {t("auth.brand.headline")} +

+

{t("auth.brand.subcopy")}

+
+ +

{t("auth.brand.footer")}

+ + +
+ +
+
{children}
+
+
+ ); +} diff --git a/frontend/src/auth/components/Field/Field.stories.tsx b/frontend/src/auth/components/Field/Field.stories.tsx new file mode 100644 index 0000000..4f59a26 --- /dev/null +++ b/frontend/src/auth/components/Field/Field.stories.tsx @@ -0,0 +1,40 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { Mail } from "lucide-react"; +import { Field } from "./Field"; + +const meta = { + component: Field, + tags: ["autodocs"], + parameters: { layout: "centered" }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], + args: { + id: "email", + label: "Email", + placeholder: "you@partner.org", + type: "email", + icon: , + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const WithValue: Story = { + args: { defaultValue: "you@partner.org" }, +}; + +export const WithError: Story = { + args: { error: "Enter a valid email address." }, +}; + +export const VisibleLabel: Story = { + args: { labelHidden: false }, +}; diff --git a/frontend/src/auth/components/Field/Field.test.tsx b/frontend/src/auth/components/Field/Field.test.tsx new file mode 100644 index 0000000..d7f8b60 --- /dev/null +++ b/frontend/src/auth/components/Field/Field.test.tsx @@ -0,0 +1,75 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import { Mail } from "lucide-react"; +import { Field, DATA_TEST_ID } from "./Field"; + +describe("Field", () => { + describe("Render tests", () => { + it("should associate the label with the input for accessibility", () => { + // GIVEN a field with a label + // WHEN it is rendered + render(} />); + + // THEN the input is reachable by its accessible name + expect(screen.getByLabelText("Email")).toBeInTheDocument(); + }); + + it("should visually hide the label by default but keep it accessible", () => { + // GIVEN a default field + // WHEN it is rendered + render(); + + // THEN the label is present but visually hidden (sr-only) + expect(screen.getByText("Email")).toHaveClass("sr-only"); + }); + + it("should show a visible label when labelHidden is false", () => { + // GIVEN labelHidden=false + // WHEN it is rendered + render(); + + // THEN the label is not sr-only + expect(screen.getByText("Email")).not.toHaveClass("sr-only"); + }); + }); + + describe("Error state", () => { + it("should announce the error and mark the input invalid", () => { + // GIVEN a field with an error + // WHEN it is rendered + render(); + + // THEN the error is announced via role=alert + expect(screen.getByRole("alert")).toHaveTextContent("Enter a valid email address."); + // AND the input is flagged invalid and points to the error via aria-describedby + const input = screen.getByLabelText("Email"); + expect(input).toHaveAttribute("aria-invalid", "true"); + expect(input).toHaveAttribute("aria-describedby", "email-error"); + }); + + it("should not render an error node when there is no error", () => { + // GIVEN a field without an error + // WHEN it is rendered + render(); + + // THEN there is no alert + expect(screen.queryByTestId(DATA_TEST_ID.FIELD_ERROR)).not.toBeInTheDocument(); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + }); + }); + + describe("Interaction", () => { + it("should forward typed input to the onChange handler", async () => { + // GIVEN a field with an onChange handler + const handleChange = vi.fn(); + render(); + + // WHEN the user types + await userEvent.type(screen.getByLabelText("Email"), "hi"); + + // THEN the handler is called for each keystroke + expect(handleChange).toHaveBeenCalledTimes(2); + }); + }); +}); diff --git a/frontend/src/auth/components/Field/Field.tsx b/frontend/src/auth/components/Field/Field.tsx new file mode 100644 index 0000000..57c469d --- /dev/null +++ b/frontend/src/auth/components/Field/Field.tsx @@ -0,0 +1,59 @@ +import { useId, type ReactNode } from "react"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { cn } from "@/lib/utils"; + +const uniqueId = "5b8e2f14-9c3a-4d67-8f21-6a0b7c1d2e3f"; + +export const DATA_TEST_ID = { + FIELD_CONTAINER: `auth-field-container-${uniqueId}`, + FIELD_ERROR: `auth-field-error-${uniqueId}`, +}; + +export interface FieldProps extends React.ComponentProps<"input"> { + label: string; + icon?: ReactNode; + error?: string; + labelHidden?: boolean; +} + +export function Field({ id, label, icon, error, labelHidden = true, className, ...inputProps }: FieldProps) { + const generatedId = useId(); + const fieldId = id ?? generatedId; + const errorId = `${fieldId}-error`; + + return ( +
+ +
+ {icon && ( + + )} + +
+ {error && ( + + )} +
+ ); +} diff --git a/frontend/src/auth/components/PasswordRequirements/PasswordRequirements.stories.tsx b/frontend/src/auth/components/PasswordRequirements/PasswordRequirements.stories.tsx new file mode 100644 index 0000000..e0e3940 --- /dev/null +++ b/frontend/src/auth/components/PasswordRequirements/PasswordRequirements.stories.tsx @@ -0,0 +1,23 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { PasswordRequirements } from "./PasswordRequirements"; + +const meta = { + component: PasswordRequirements, + tags: ["autodocs"], + parameters: { layout: "centered" }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Empty: Story = { + args: { password: "" }, +}; + +export const Partial: Story = { + args: { password: "abc" }, +}; + +export const AllMet: Story = { + args: { password: "Passw0rd!" }, +}; diff --git a/frontend/src/auth/components/PasswordRequirements/PasswordRequirements.test.tsx b/frontend/src/auth/components/PasswordRequirements/PasswordRequirements.test.tsx new file mode 100644 index 0000000..1fbe524 --- /dev/null +++ b/frontend/src/auth/components/PasswordRequirements/PasswordRequirements.test.tsx @@ -0,0 +1,50 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { PasswordRequirements, isStrongPassword } from "./PasswordRequirements"; + +describe("PasswordRequirements", () => { + it("should list all five requirements", () => { + // GIVEN an empty password + const givenPassword = ""; + // WHEN rendered + render(); + + // THEN every rule is shown + expect(screen.getByText(/at least 8 characters long/)).toBeInTheDocument(); + expect(screen.getByText(/one uppercase letter/)).toBeInTheDocument(); + expect(screen.getByText(/one lowercase letter/)).toBeInTheDocument(); + expect(screen.getByText(/one number/)).toBeInTheDocument(); + expect(screen.getByText(/one special character/)).toBeInTheDocument(); + }); + + it("should mark a met rule green and an unmet rule as an error", () => { + // GIVEN a password that only satisfies the lowercase rule + const givenPassword = "abc"; + // WHEN rendered + render(); + + // THEN the lowercase rule is met + expect(screen.getByText(/one lowercase letter/).closest("li")).toHaveClass("text-green-3"); + expect(screen.getByText(/at least 8 characters long/).closest("li")).toHaveClass("text-destructive"); + }); +}); + +describe("isStrongPassword", () => { + it("should reject passwords missing any rule", () => { + // GIVEN passwords that each miss a rule + const givenPasswords = ["", "a", "password", "Password1", "Passw0rd"]; + // THEN they are rejected + expect(isStrongPassword(givenPasswords[0])).toBe(false); + expect(isStrongPassword(givenPasswords[1])).toBe(false); // no upper/number/special + expect(isStrongPassword(givenPasswords[2])).toBe(false); // no upper/number/special + expect(isStrongPassword(givenPasswords[3])).toBe(false); // no special + expect(isStrongPassword(givenPasswords[4])).toBe(false); // no special + }); + + it("should accept a password that satisfies every rule", () => { + // GIVEN a strong password + const givenPassword = "Passw0rd!"; + // THEN it is accepted + expect(isStrongPassword(givenPassword)).toBe(true); + }); +}); diff --git a/frontend/src/auth/components/PasswordRequirements/PasswordRequirements.tsx b/frontend/src/auth/components/PasswordRequirements/PasswordRequirements.tsx new file mode 100644 index 0000000..b97c858 --- /dev/null +++ b/frontend/src/auth/components/PasswordRequirements/PasswordRequirements.tsx @@ -0,0 +1,33 @@ +import { useTranslation } from "react-i18next"; +import { cn } from "@/lib/utils"; + +const uniqueId = "b2f7c9d1-3e4a-4b6c-8d0e-1f2a3b4c5d6e"; + +export const DATA_TEST_ID = { + CONTAINER: `password-requirements-${uniqueId}`, +}; + +export const isStrongPassword = (value: string) => + /.{8,}/.test(value) && /[a-z]/.test(value) && /[A-Z]/.test(value) && /\d/.test(value) && /[!-/:-@[-`{-~]/.test(value); + +export function PasswordRequirements({ password }: { password: string }) { + const { t } = useTranslation(); + + const rules = [ + { met: /.{8,}/.test(password), label: t("auth.passwordRules.length") }, + { met: /[a-z]/.test(password), label: t("auth.passwordRules.lowercase") }, + { met: /[A-Z]/.test(password), label: t("auth.passwordRules.uppercase") }, + { met: /\d/.test(password), label: t("auth.passwordRules.number") }, + { met: /[!-/:-@[-`{-~]/.test(password), label: t("auth.passwordRules.special") }, + ]; + + return ( +
    + {rules.map((rule) => ( +
  • + * {rule.label} +
  • + ))} +
+ ); +} diff --git a/frontend/src/auth/components/SocialAuth/SocialAuth.stories.tsx b/frontend/src/auth/components/SocialAuth/SocialAuth.stories.tsx new file mode 100644 index 0000000..2c0067b --- /dev/null +++ b/frontend/src/auth/components/SocialAuth/SocialAuth.stories.tsx @@ -0,0 +1,26 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { fn } from "storybook/test"; +import { SocialAuth } from "./SocialAuth"; + +const meta = { + component: SocialAuth, + tags: ["autodocs"], + parameters: { layout: "centered" }, + args: { onGoogle: fn() }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const Disabled: Story = { + args: { disabled: true }, +}; diff --git a/frontend/src/auth/components/SocialAuth/SocialAuth.test.tsx b/frontend/src/auth/components/SocialAuth/SocialAuth.test.tsx new file mode 100644 index 0000000..7968025 --- /dev/null +++ b/frontend/src/auth/components/SocialAuth/SocialAuth.test.tsx @@ -0,0 +1,37 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import { SocialAuth } from "./SocialAuth"; + +describe("SocialAuth", () => { + it("should render the divider and the Continue with Google button", () => { + // GIVEN the component + // WHEN rendered + render(); + + // THEN the real divider and Google button copy are shown + expect(screen.getByText("OR")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Continue with Google" })).toBeInTheDocument(); + }); + + it("should call onGoogle when the Google button is clicked", async () => { + // GIVEN a handler + const onGoogle = vi.fn(); + render(); + + // WHEN the button is clicked + await userEvent.click(screen.getByRole("button", { name: "Continue with Google" })); + + // THEN the handler fires once + expect(onGoogle).toHaveBeenCalledOnce(); + }); + + it("should disable the Google button when disabled", () => { + // GIVEN disabled=true + // WHEN rendered + render(); + + // THEN the button is disabled + expect(screen.getByRole("button", { name: "Continue with Google" })).toBeDisabled(); + }); +}); diff --git a/frontend/src/auth/components/SocialAuth/SocialAuth.tsx b/frontend/src/auth/components/SocialAuth/SocialAuth.tsx new file mode 100644 index 0000000..f1d069a --- /dev/null +++ b/frontend/src/auth/components/SocialAuth/SocialAuth.tsx @@ -0,0 +1,40 @@ +import { useTranslation } from "react-i18next"; +import { FcGoogle } from "react-icons/fc"; +import { Button } from "@/components/ui/button"; + +const uniqueId = "a1c4e6f8-0b2d-4e6a-9c8b-3f5d7a9b1c2e"; + +export const DATA_TEST_ID = { + DIVIDER: `auth-social-divider-${uniqueId}`, + GOOGLE_BUTTON: `auth-social-google-button-${uniqueId}`, +}; + +export interface SocialAuthProps { + onGoogle: () => void; + disabled?: boolean; +} + +export function SocialAuth({ onGoogle, disabled }: SocialAuthProps) { + const { t } = useTranslation(); + + return ( +
+
+ + {t("auth.social.divider")} + +
+ +
+ ); +} diff --git a/frontend/src/auth/services/Authentication.service.factory.ts b/frontend/src/auth/services/Authentication.service.factory.ts new file mode 100644 index 0000000..4ca6082 --- /dev/null +++ b/frontend/src/auth/services/Authentication.service.factory.ts @@ -0,0 +1,7 @@ +import { AuthenticationService } from "./Authentication.service"; + +export class AuthenticationServiceFactory { + static getCurrentAuthenticationService(): AuthenticationService { + return AuthenticationService.getInstance(); + } +} diff --git a/frontend/src/auth/services/Authentication.service.ts b/frontend/src/auth/services/Authentication.service.ts new file mode 100644 index 0000000..f769d0d --- /dev/null +++ b/frontend/src/auth/services/Authentication.service.ts @@ -0,0 +1,40 @@ +import type { LoginRequest, RegisterRequest } from "@/auth/auth.types"; + +export const AUTH_API_BASE = "/api/auth"; + +export class AuthApiError extends Error { + readonly status: number; + readonly code: string; + + constructor(status: number, code: string, message: string) { + super(message); + this.name = "AuthApiError"; + this.status = status; + this.code = code; + } +} + +export class AuthenticationService { + private static instance: AuthenticationService | null = null; + + static getInstance(): AuthenticationService { + AuthenticationService.instance ??= new AuthenticationService(); + return AuthenticationService.instance; + } + + async login(_request: LoginRequest): Promise { + // TODO: call the auth API. + } + + async register(_request: RegisterRequest): Promise { + // TODO: call the auth API. + } + + async loginWithGoogle(): Promise { + // TODO: call the auth API. + } + + logout(): void { + // TODO: clear the session. + } +} diff --git a/frontend/src/components/ui/button.tsx b/frontend/src/components/ui/button.tsx index 3c43c0c..93b5d4f 100644 --- a/frontend/src/components/ui/button.tsx +++ b/frontend/src/components/ui/button.tsx @@ -10,6 +10,7 @@ const buttonVariants = cva( variants: { variant: { default: "bg-primary text-primary-foreground hover:bg-primary/90", + brand: "bg-tabiya-green text-tabiya-blue disabled:bg-gray-200 disabled:text-gray-400 disabled:opacity-100", destructive: "bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40", outline: diff --git a/frontend/src/components/ui/label.tsx b/frontend/src/components/ui/label.tsx new file mode 100644 index 0000000..a7c8873 --- /dev/null +++ b/frontend/src/components/ui/label.tsx @@ -0,0 +1,19 @@ +import * as React from "react"; +import { Label as LabelPrimitive } from "radix-ui"; + +import { cn } from "@/lib/utils"; + +function Label({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +export { Label }; diff --git a/frontend/src/i18n/locales/en-GB/translation.json b/frontend/src/i18n/locales/en-GB/translation.json index 3217162..45567dd 100644 --- a/frontend/src/i18n/locales/en-GB/translation.json +++ b/frontend/src/i18n/locales/en-GB/translation.json @@ -19,5 +19,62 @@ "defaultMessage": "{{appName}} ran into an unexpected error. Try reloading the page.", "reload": "Reload page" } + }, + "auth": { + "brand": { + "eyebrow": "{{appName}}", + "headline": "The dashboard behind every deployment.", + "subcopy": "See who you're reaching, how engaged they are, and how each module of the suite is performing — for your cohort, your institution, or across all of them.", + "footer": "Open-source digital public infrastructure for jobs." + }, + "fields": { + "fullName": { "label": "Full name", "placeholder": "Full name" }, + "organization": { "label": "Organization", "placeholder": "Organization" }, + "email": { "label": "Email", "placeholder": "Email" }, + "password": { "label": "Password", "placeholder": "Password" }, + "newPassword": { "label": "Password", "placeholder": "Password" }, + "confirmPassword": { "label": "Confirm password", "placeholder": "Confirm password" } + }, + "login": { + "title": "Sign in", + "subtitle": "Welcome back. Enter your details to continue.", + "forgotPassword": "Forgot password?", + "submit": "Sign in", + "footerPrompt": "New to {{appName}}?", + "footerAction": "Create an account" + }, + "register": { + "title": "Create your account", + "subtitle": "Set up access to your deployment dashboard.", + "submit": "Create account", + "footerPrompt": "Already have an account?", + "footerAction": "Sign in" + }, + "social": { + "divider": "OR", + "google": "Continue with Google" + }, + "validation": { + "emailRequired": "Email is required.", + "emailInvalid": "Enter a valid email address.", + "passwordRequired": "Password is required.", + "fullNameRequired": "Full name is required.", + "organizationRequired": "Organization is required.", + "confirmPasswordRequired": "Please confirm your password.", + "passwordsMismatch": "Passwords do not match." + }, + "passwordRules": { + "length": "Password must be at least 8 characters long.", + "lowercase": "Password must include at least one lowercase letter.", + "uppercase": "Password must include at least one uppercase letter.", + "number": "Password must include at least one number.", + "special": "Password must include at least one special character such as: !@#$%*& etc." + }, + "errors": { + "invalidCredentials": "Email or password is incorrect.", + "emailTaken": "An account with that email already exists.", + "generic": "Something went wrong. Please try again." + }, + "signOut": "Sign out" } } diff --git a/frontend/src/index.css b/frontend/src/index.css index 919a37f..b28edc0 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -150,7 +150,7 @@ --accent-foreground: var(--tabiya-blue); - --destructive: #c0522f; + --destructive: #a8431f; --destructive-foreground: var(--white); --border: var(--border-subtle); diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 2e62159..efd538a 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -2,7 +2,7 @@ import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import * as Sentry from "@sentry/react"; import "./index.css"; -import App from "./App.tsx"; +import App from "@/app"; import { applyBranding } from "./branding/applyBranding"; import { initI18n } from "./i18n/i18n"; import { initSentry } from "./sentry/sentryInit"; diff --git a/frontend/src/pages/Login/Login.stories.tsx b/frontend/src/pages/Login/Login.stories.tsx new file mode 100644 index 0000000..1d6a62a --- /dev/null +++ b/frontend/src/pages/Login/Login.stories.tsx @@ -0,0 +1,13 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { Login } from "./Login"; + +const meta = { + component: Login, + tags: ["autodocs"], + parameters: { layout: "fullscreen" }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/frontend/src/pages/Login/Login.test.tsx b/frontend/src/pages/Login/Login.test.tsx new file mode 100644 index 0000000..4901ba1 --- /dev/null +++ b/frontend/src/pages/Login/Login.test.tsx @@ -0,0 +1,101 @@ +import { render, screen, waitFor } from "@/_test_utilities/test-utils"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { routerPaths } from "@/app/routerPaths"; +import { Login, DATA_TEST_ID } from "./Login"; + +const mockNavigate = vi.fn(); +vi.mock("react-router-dom", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, useNavigate: () => mockNavigate }; +}); + +describe("Login", () => { + beforeEach(() => { + mockNavigate.mockReset(); + }); + + describe("Render tests", () => { + it("should render the sign-in heading, fields, and actions", () => { + // GIVEN the login page + // WHEN rendered + render(); + + // THEN the real copy, both inputs, and both buttons are present + expect(screen.getByRole("heading", { name: "Sign in" })).toBeInTheDocument(); + expect(screen.getByLabelText("Email")).toBeInTheDocument(); + expect(screen.getByLabelText("Password")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /Sign in/ })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Continue with Google" })).toBeInTheDocument(); + }); + + it("should link to the register page in the footer", () => { + // GIVEN the login page + // WHEN rendered + render(); + + // THEN the footer link points to /register (hash-prefixed under HashRouter) + expect(screen.getByTestId(DATA_TEST_ID.REGISTER_LINK)).toHaveAttribute("href", "#/register"); + }); + + it("should render an inert Forgot password link", () => { + // GIVEN the login page + // WHEN rendered + render(); + + // THEN the forgot-password link is present but goes nowhere real yet + const link = screen.getByTestId(DATA_TEST_ID.FORGOT_PASSWORD_LINK); + expect(link).toHaveTextContent("Forgot password?"); + expect(link).toHaveAttribute("href", "#"); + }); + }); + + describe("Validation", () => { + it("should keep the submit button disabled until both fields are filled", async () => { + // GIVEN the empty form + render(); + const submit = screen.getByTestId(DATA_TEST_ID.SUBMIT_BUTTON); + + // THEN submit starts disabled + expect(submit).toBeDisabled(); + + // WHEN only the email is filled + await userEvent.type(screen.getByLabelText("Email"), "you@partner.org"); + + // THEN submit stays disabled + expect(submit).toBeDisabled(); + + // WHEN the password is also filled + await userEvent.type(screen.getByLabelText("Password"), "s3cret!"); + + // THEN submit becomes enabled + expect(submit).toBeEnabled(); + }); + }); + + describe("Submission", () => { + it("should navigate to the root when the form is submitted", async () => { + // GIVEN a filled-in form + render(); + await userEvent.type(screen.getByLabelText("Email"), "you@partner.org"); + await userEvent.type(screen.getByLabelText("Password"), "s3cret!"); + + // WHEN submitting + await userEvent.click(screen.getByTestId(DATA_TEST_ID.SUBMIT_BUTTON)); + + // THEN the app navigates to the root + await waitFor(() => expect(mockNavigate).toHaveBeenCalledWith(routerPaths.ROOT)); + }); + + it("should navigate to the root after Continue with Google", async () => { + // GIVEN the login page + render(); + + // WHEN clicking Continue with Google + await userEvent.click(screen.getByRole("button", { name: "Continue with Google" })); + + // THEN the app navigates to the root + await waitFor(() => expect(mockNavigate).toHaveBeenCalledWith(routerPaths.ROOT)); + }); + }); +}); diff --git a/frontend/src/pages/Login/Login.tsx b/frontend/src/pages/Login/Login.tsx new file mode 100644 index 0000000..c435b91 --- /dev/null +++ b/frontend/src/pages/Login/Login.tsx @@ -0,0 +1,147 @@ +import { useState, type SyntheticEvent } from "react"; +import { useTranslation } from "react-i18next"; +import { Link, useNavigate } from "react-router-dom"; +import { ArrowRight, Mail, Lock } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { getAppName } from "@/branding/brandingConfig"; +import { AuthLayout } from "@/auth/components/AuthLayout/AuthLayout"; +import { Field } from "@/auth/components/Field/Field"; +import { SocialAuth } from "@/auth/components/SocialAuth/SocialAuth"; +import { routerPaths } from "@/app/routerPaths"; +import { AuthApiError } from "@/auth/services/Authentication.service"; +import { AuthenticationServiceFactory } from "@/auth/services/Authentication.service.factory"; + +const uniqueId = "9f2a7b3c-4d5e-6a7b-8c9d-0e1f2a3b4c5d"; + +export const DATA_TEST_ID = { + CONTAINER: `login-container-${uniqueId}`, + FORM: `login-form-${uniqueId}`, + SUBMIT_BUTTON: `login-submit-button-${uniqueId}`, + FORGOT_PASSWORD_LINK: `login-forgot-password-link-${uniqueId}`, + FORM_ERROR: `login-form-error-${uniqueId}`, + REGISTER_LINK: `login-register-link-${uniqueId}`, +}; + +export function Login() { + const { t } = useTranslation(); + const navigate = useNavigate(); + const authService = AuthenticationServiceFactory.getCurrentAuthenticationService(); + + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [formError, setFormError] = useState(null); + const [submitting, setSubmitting] = useState(false); + + // Login doesn't validate the email format — the account already exists; we + // only guard against submitting an empty form. + const isFormValid = email.trim().length > 0 && password.length > 0; + + const handleSubmit = async (event: SyntheticEvent) => { + event.preventDefault(); + if (!isFormValid) return; + + setFormError(null); + setSubmitting(true); + try { + await authService.login({ email, password }); + navigate(routerPaths.ROOT); + } catch (error) { + if (error instanceof AuthApiError && error.code === "invalid_credentials") { + setFormError(t("auth.errors.invalidCredentials")); + } else { + setFormError(t("auth.errors.generic")); + } + } finally { + setSubmitting(false); + } + }; + + const handleGoogle = async () => { + setFormError(null); + setSubmitting(true); + try { + await authService.loginWithGoogle(); + navigate(routerPaths.ROOT); + } catch { + setFormError(t("auth.errors.generic")); + } finally { + setSubmitting(false); + } + }; + + return ( + +
+
+

{t("auth.login.title")}

+

{t("auth.login.subtitle")}

+
+ +
+ {formError && ( +

+ {formError} +

+ )} + + } + value={email} + onChange={(e) => setEmail(e.target.value)} + /> + +
+ } + value={password} + onChange={(e) => setPassword(e.target.value)} + /> + +
+ + + + + + +

+ {t("auth.login.footerPrompt", { appName: getAppName() })}{" "} + + {t("auth.login.footerAction")} + +

+
+
+ ); +} diff --git a/frontend/src/pages/Register/Register.stories.tsx b/frontend/src/pages/Register/Register.stories.tsx new file mode 100644 index 0000000..b0db038 --- /dev/null +++ b/frontend/src/pages/Register/Register.stories.tsx @@ -0,0 +1,13 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { Register } from "./Register"; + +const meta = { + component: Register, + tags: ["autodocs"], + parameters: { layout: "fullscreen" }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/frontend/src/pages/Register/Register.test.tsx b/frontend/src/pages/Register/Register.test.tsx new file mode 100644 index 0000000..083e3d7 --- /dev/null +++ b/frontend/src/pages/Register/Register.test.tsx @@ -0,0 +1,114 @@ +import { render, screen, waitFor } from "@/_test_utilities/test-utils"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { routerPaths } from "@/app/routerPaths"; +import { Register, DATA_TEST_ID } from "./Register"; + +const mockNavigate = vi.fn(); +vi.mock("react-router-dom", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, useNavigate: () => mockNavigate }; +}); + +async function fillValidForm(email = "ada@partner.org") { + await userEvent.type(screen.getByLabelText("Full name"), "Ada Lovelace"); + await userEvent.type(screen.getByLabelText("Organization"), "Analytical Engines"); + await userEvent.type(screen.getByLabelText("Email"), email); + await userEvent.type(screen.getByLabelText("Password"), "Passw0rd!"); + await userEvent.type(screen.getByLabelText("Confirm password"), "Passw0rd!"); +} + +describe("Register", () => { + beforeEach(() => { + mockNavigate.mockReset(); + }); + + describe("Render tests", () => { + it("should render the heading and all five fields", () => { + // GIVEN the register page + // WHEN rendered + render(); + + // THEN the real copy and every field are present + expect(screen.getByRole("heading", { name: "Create your account" })).toBeInTheDocument(); + expect(screen.getByLabelText("Full name")).toBeInTheDocument(); + expect(screen.getByLabelText("Organization")).toBeInTheDocument(); + expect(screen.getByLabelText("Email")).toBeInTheDocument(); + expect(screen.getByLabelText("Password")).toBeInTheDocument(); + expect(screen.getByLabelText("Confirm password")).toBeInTheDocument(); + }); + + it("should link back to the login page in the footer", () => { + // GIVEN the register page + // WHEN rendered + render(); + + // THEN the footer link points to /login (hash-prefixed under HashRouter) + expect(screen.getByTestId(DATA_TEST_ID.LOGIN_LINK)).toHaveAttribute("href", "#/login"); + }); + }); + + describe("Live validation", () => { + it("should keep the submit button disabled until the whole form is valid", async () => { + // GIVEN the empty form + render(); + const submit = screen.getByTestId(DATA_TEST_ID.SUBMIT_BUTTON); + + // THEN submit starts disabled + expect(submit).toBeDisabled(); + + // WHEN every field is filled validly + await fillValidForm(); + + // THEN submit becomes enabled + expect(submit).toBeEnabled(); + }); + + it("should show the password requirements as the user types and keep submit disabled", async () => { + // GIVEN a weak password (a single character) + render(); + await userEvent.type(screen.getByLabelText("Password"), "a"); + + // THEN the requirement checklist appears and submit stays disabled + expect(screen.getByText(/at least 8 characters long/)).toBeInTheDocument(); + expect(screen.getByText(/one uppercase letter/)).toBeInTheDocument(); + expect(screen.getByTestId(DATA_TEST_ID.SUBMIT_BUTTON)).toBeDisabled(); + }); + + it("should show the mismatch error live once the passwords differ", async () => { + // GIVEN a strong password and a different confirmation + render(); + await userEvent.type(screen.getByLabelText("Password"), "Passw0rd!"); + await userEvent.type(screen.getByLabelText("Confirm password"), "Different1!"); + + // THEN the mismatch error shows immediately and submit stays disabled + expect(screen.getByText("Passwords do not match.")).toBeInTheDocument(); + expect(screen.getByTestId(DATA_TEST_ID.SUBMIT_BUTTON)).toBeDisabled(); + }); + }); + + describe("Submission", () => { + it("should navigate to the root when the form is valid and submitted", async () => { + // GIVEN a valid form + render(); + await fillValidForm(); + + // WHEN submitting + await userEvent.click(screen.getByTestId(DATA_TEST_ID.SUBMIT_BUTTON)); + + // THEN the app navigates to the root + await waitFor(() => expect(mockNavigate).toHaveBeenCalledWith(routerPaths.ROOT)); + }); + + it("should navigate to the root after Continue with Google", async () => { + // GIVEN the register page + render(); + + // WHEN clicking Continue with Google + await userEvent.click(screen.getByRole("button", { name: "Continue with Google" })); + + // THEN the Google flow signs in and navigates to the root + await waitFor(() => expect(mockNavigate).toHaveBeenCalledWith(routerPaths.ROOT)); + }); + }); +}); diff --git a/frontend/src/pages/Register/Register.tsx b/frontend/src/pages/Register/Register.tsx new file mode 100644 index 0000000..66c45b5 --- /dev/null +++ b/frontend/src/pages/Register/Register.tsx @@ -0,0 +1,189 @@ +import { useState, type SyntheticEvent } from "react"; +import { useTranslation } from "react-i18next"; +import { Link, useNavigate } from "react-router-dom"; +import { ArrowRight, Mail, Lock, User, Building2 } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { AuthLayout } from "@/auth/components/AuthLayout/AuthLayout"; +import { Field } from "@/auth/components/Field/Field"; +import { SocialAuth } from "@/auth/components/SocialAuth/SocialAuth"; +import { PasswordRequirements, isStrongPassword } from "@/auth/components/PasswordRequirements/PasswordRequirements"; +import { routerPaths } from "@/app/routerPaths"; +import { AuthApiError } from "@/auth/services/Authentication.service"; +import { AuthenticationServiceFactory } from "@/auth/services/Authentication.service.factory"; + +const isValidEmail = (value: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value.trim()); + +const uniqueId = "c7e9a1b3-2d4f-5a6b-7c8e-9f0a1b2c3d4e"; + +export const DATA_TEST_ID = { + CONTAINER: `register-container-${uniqueId}`, + FORM: `register-form-${uniqueId}`, + SUBMIT_BUTTON: `register-submit-button-${uniqueId}`, + FORM_ERROR: `register-form-error-${uniqueId}`, + LOGIN_LINK: `register-login-link-${uniqueId}`, +}; + +export function Register() { + const { t } = useTranslation(); + const navigate = useNavigate(); + const authService = AuthenticationServiceFactory.getCurrentAuthenticationService(); + + const [fullName, setFullName] = useState(""); + const [organization, setOrganization] = useState(""); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + const [formError, setFormError] = useState(null); + const [submitting, setSubmitting] = useState(false); + + const emailValid = isValidEmail(email); + const passwordValid = isStrongPassword(password); + const passwordsMatch = password === confirmPassword; + + const emailError = email.length > 0 && !emailValid ? t("auth.validation.emailInvalid") : undefined; + const confirmError = + confirmPassword.length > 0 && !passwordsMatch ? t("auth.validation.passwordsMismatch") : undefined; + + const isFormValid = + fullName.trim().length > 0 && + organization.trim().length > 0 && + emailValid && + passwordValid && + confirmPassword.length > 0 && + passwordsMatch; + + const handleSubmit = async (event: SyntheticEvent) => { + event.preventDefault(); + if (!isFormValid) return; + + setFormError(null); + setSubmitting(true); + try { + await authService.register({ fullName, organization, email, password }); + navigate(routerPaths.ROOT); + } catch (error) { + if (error instanceof AuthApiError && error.code === "email_taken") { + setFormError(t("auth.errors.emailTaken")); + } else { + setFormError(t("auth.errors.generic")); + } + } finally { + setSubmitting(false); + } + }; + + const handleGoogle = async () => { + setFormError(null); + setSubmitting(true); + try { + await authService.loginWithGoogle(); + navigate(routerPaths.ROOT); + } catch { + setFormError(t("auth.errors.generic")); + } finally { + setSubmitting(false); + } + }; + + return ( + +
+
+

{t("auth.register.title")}

+

{t("auth.register.subtitle")}

+
+ +
+ {formError && ( +

+ {formError} +

+ )} + +
+ } + value={fullName} + onChange={(e) => setFullName(e.target.value)} + /> + } + value={organization} + onChange={(e) => setOrganization(e.target.value)} + /> +
+ + } + value={email} + onChange={(e) => setEmail(e.target.value)} + error={emailError} + /> + +
+ } + value={password} + onChange={(e) => setPassword(e.target.value)} + /> + {password.length > 0 && !passwordValid && } +
+ + } + value={confirmPassword} + onChange={(e) => setConfirmPassword(e.target.value)} + error={confirmError} + /> + + + + + + +

+ {t("auth.register.footerPrompt")}{" "} + + {t("auth.register.footerAction")} + +

+
+
+ ); +} diff --git a/frontend/yarn.lock b/frontend/yarn.lock index 7cd2000..1be9156 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -2469,7 +2469,7 @@ convert-source-map@^2.0.0: resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-2.0.0.tgz#4b560f649fc4e918dd0ab75cf4961e8bc882d82a" integrity sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg== -cookie@^1.1.1: +cookie@^1.0.1, cookie@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/cookie/-/cookie-1.1.1.tgz#3bb9bdfc82369db9c2f69c93c9c3ceb310c88b3c" integrity sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ== @@ -3511,6 +3511,11 @@ react-i18next@^17.0.10: html-parse-stringify "^3.0.1" use-sync-external-store "^1.6.0" +react-icons@^5.7.0: + version "5.7.0" + resolved "https://registry.yarnpkg.com/react-icons/-/react-icons-5.7.0.tgz#8969db00968ffdfc57fdc2ec9dd1ed88e35b3de9" + integrity sha512-LBLy340Rzqy6+/yVhZKT3B/QpP1BZaesGqasf09HPOBzRarcDIFH0WwXlXQfE7q7ipxK4MSiC5DIBWURCny6fw== + react-is@^17.0.1: version "17.0.2" resolved "https://registry.yarnpkg.com/react-is/-/react-is-17.0.2.tgz#e691d4a8e9c789365655539ab372762b0efb54f0" @@ -3535,6 +3540,21 @@ react-remove-scroll@^2.7.2: use-callback-ref "^1.3.3" use-sidecar "^1.1.3" +react-router-dom@^7.18.1: + version "7.18.1" + resolved "https://registry.yarnpkg.com/react-router-dom/-/react-router-dom-7.18.1.tgz#0d1b138e291393059ad481c3e10e366385a978a4" + integrity sha512-KaZh+X/6UtEp28x51AUYZDMg9NGoz2ja3dNHa+ta/tk40vCzKhQ/RypCWBMLbmDr6//E24Vv5uPsrqXFozdkAg== + dependencies: + react-router "7.18.1" + +react-router@7.18.1: + version "7.18.1" + resolved "https://registry.yarnpkg.com/react-router/-/react-router-7.18.1.tgz#61259d1594b95c1ace299ee4c57453570f0c22f1" + integrity sha512-GDLgg3i3uM0aeJO3Fm+TCS+sDQ7gu12T6x0qdTEzcwqEfleci7JwugVNIF3U//0FWKnJT7ptG+20B2jfDqnZAg== + dependencies: + cookie "^1.0.1" + set-cookie-parser "^2.6.0" + react-style-singleton@^2.2.2, react-style-singleton@^2.2.3: version "2.2.3" resolved "https://registry.yarnpkg.com/react-style-singleton/-/react-style-singleton-2.2.3.tgz#4265608be69a4d70cfe3047f2c6c88b2c3ace388" @@ -3643,6 +3663,11 @@ semver@^7.3.5, semver@^7.5.3, semver@^7.7.3: resolved "https://registry.yarnpkg.com/semver/-/semver-7.8.5.tgz#39b646037dd50c14fb451e7e4cac58ed8b863f69" integrity sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA== +set-cookie-parser@^2.6.0: + version "2.7.2" + resolved "https://registry.yarnpkg.com/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz#ccd08673a9ae5d2e44ea2a2de25089e67c7edf68" + integrity sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw== + set-cookie-parser@^3.0.1: version "3.1.2" resolved "https://registry.yarnpkg.com/set-cookie-parser/-/set-cookie-parser-3.1.2.tgz#f4e490298759d756a68eabcbcd0fc9261ad0fee0"