Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions dembrane/frontend/e2e/register-verify-login.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { createHmac } from "node:crypto";
import { expect, test } from "@playwright/test";

// Register, verify, log in: the whole way in for a new person, through the real UI and API.
//
// The verification email is not readable from here (locally it stays in the API's memory),
// so the spec mints the same link the email carries: Better Auth's verification token is an
// HS256 JWT of { email } signed with the API's AUTH_SECRET. Pass that secret as
// E2E_AUTH_SECRET; without it the spec skips. Needs the dashboard (E2E_BASE_URL, default
// :5173) talking to a running API with a migrated database. Not a PR gate.
const AUTH_SECRET = process.env.E2E_AUTH_SECRET ?? "";

const base64url = (value: string | Buffer) =>
Buffer.from(value).toString("base64url");

function verificationToken(email: string, secret: string): string {
const now = Math.floor(Date.now() / 1000);
const header = base64url(JSON.stringify({ alg: "HS256" }));
const payload = base64url(
JSON.stringify({ email: email.toLowerCase(), exp: now + 3600, iat: now }),
);
const signature = createHmac("sha256", secret)
.update(`${header}.${payload}`)
.digest("base64url");
return `${header}.${payload}.${signature}`;
}

test.skip(!AUTH_SECRET, "set E2E_AUTH_SECRET to the API's AUTH_SECRET");

test("a new person registers, verifies their email and logs in", async ({
page,
}) => {
test.setTimeout(60_000);
const email = `e2e-${Date.now()}@example.com`;
const password = "Abcdefgh1!xyz";

await page.goto("/register");
await page.getByTestId("auth-register-first-name-input").fill("E2E");
await page.getByTestId("auth-register-email-input").fill(email);
await page.getByTestId("auth-register-terms-checkbox").check();
await page.getByTestId("auth-register-continue-button").click();
await page.getByTestId("auth-register-password-input").fill(password);
await page.getByTestId("auth-register-confirm-password-input").fill(password);
await page.getByTestId("auth-register-submit-button").click();
await expect(page.getByTestId("auth-register-verify-step")).toContainText(
email,
);

// Logging in before verifying is refused with the resend offer.
await page.goto("/login");
await page.getByTestId("auth-login-email-input").fill(email);
await page.getByTestId("auth-login-password-input").fill(password);
await page.getByTestId("auth-login-submit-button").click();
await expect(page.getByTestId("auth-resend-verification")).toBeVisible();

await page.goto(
`/verify-email?token=${verificationToken(email, AUTH_SECRET)}`,
);
await page.waitForURL(/\/login\?verified=1/, { timeout: 15_000 });

await page.getByTestId("auth-login-email-input").fill(email);
await page.getByTestId("auth-login-password-input").fill(password);
await page.getByTestId("auth-login-submit-button").click();
// A new account lands on onboarding (or the home list once that is done).
await page.waitForURL(/\/(onboarding|o)\b/, { timeout: 20_000 });
});
51 changes: 51 additions & 0 deletions dembrane/frontend/src/components/auth/LoginBlockedNotice.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { Trans } from "@lingui/react/macro";
import { Alert, Anchor, Button, Stack, Text } from "@mantine/core";
import { ShieldWarningIcon } from "@phosphor-icons/react";
import { SUPPORT_EMAIL } from "@/components/error/actions";
import { testId } from "@/lib/testUtils";

/**
* A login refused before it reached dembrane (a 403 that carries no dembrane code). It
* says only what is known, offers the emailed sign-in code when the page has one, and
* names support. It never guesses the cause.
*/
export const LoginBlockedNotice = ({
onUseCode,
}: {
/** Switches to the emailed sign-in code; the button is left out without it. */
onUseCode?: () => void;
}) => (
<Alert
color="red"
icon={<ShieldWarningIcon size={20} />}
{...testId("auth-login-blocked")}
>
<Stack gap="xs" align="flex-start">
<Text size="sm">
<Trans>
Your login was stopped before it reached dembrane, so we could not
check your details. Try another network or another browser.
</Trans>
</Text>
{onUseCode && (
<Button
size="xs"
variant="outline"
onClick={onUseCode}
{...testId("auth-login-blocked-use-code")}
>
<Trans>Email me a sign-in code instead</Trans>
</Button>
)}
<Text size="sm">
<Trans>
Still stuck? Email{" "}
<Anchor href={`mailto:${SUPPORT_EMAIL}`} inherit>
{SUPPORT_EMAIL}
</Anchor>{" "}
and we will help you log in.
</Trans>
</Text>
</Stack>
</Alert>
);
101 changes: 101 additions & 0 deletions dembrane/frontend/src/components/auth/LoginNotices.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
// @vitest-environment jsdom
import { i18n } from "@lingui/core";
import { I18nProvider } from "@lingui/react";
import { MantineProvider } from "@mantine/core";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import type { ReactNode } from "react";
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { LoginBlockedNotice } from "./LoginBlockedNotice";
import { formatWait, LoginRateLimitNotice } from "./LoginRateLimitNotice";
import { SignedInRecoveryNotice } from "./SignedInRecoveryNotice";

beforeAll(() => {
i18n.load("en", {});
i18n.activate("en");
window.matchMedia =
window.matchMedia ||
((query: string) => ({
addEventListener: () => {},
addListener: () => {},
dispatchEvent: () => false,
matches: false,
media: query,
onchange: null,
removeEventListener: () => {},
removeListener: () => {},
}));
});

afterEach(cleanup);

const show = (ui: ReactNode) =>
render(
<I18nProvider i18n={i18n}>
<MantineProvider>{ui}</MantineProvider>
</I18nProvider>,
);

describe("LoginRateLimitNotice", () => {
it("formats the wait as a clock", () => {
expect(formatWait(75)).toBe("1:15");
expect(formatWait(9)).toBe("0:09");
expect(formatWait(0)).toBe("0:00");
expect(formatWait(-3)).toBe("0:00");
});

it("counts down for a password login without mentioning a code", () => {
show(<LoginRateLimitNotice seconds={42} />);
const notice = screen.getByTestId("auth-login-rate-limit");
expect(notice.textContent).toContain("Too many login attempts");
expect(notice.textContent).not.toMatch(/code/i);
expect(screen.getByTestId("auth-login-rate-limit-wait").textContent).toBe(
"You can try again in 0:42.",
);
});

it("keeps the code wording for the code sign-in", () => {
show(<LoginRateLimitNotice seconds={5} flow="code" />);
expect(screen.getByTestId("auth-login-rate-limit").textContent).toContain(
"request a new code",
);
});

it("says when the wait is over", () => {
show(<LoginRateLimitNotice seconds={0} />);
expect(screen.getByTestId("auth-login-rate-limit-wait").textContent).toBe(
"You can try again now.",
);
});
});

describe("LoginBlockedNotice", () => {
it("offers the emailed code and names support", () => {
const onUseCode = vi.fn();
show(<LoginBlockedNotice onUseCode={onUseCode} />);
const notice = screen.getByTestId("auth-login-blocked");
expect(notice.textContent).toContain("stopped before it reached dembrane");
expect(notice.textContent).toContain("another network or another browser");
expect(
screen.getByRole("link", { name: "support@dembrane.com" }),
).toHaveProperty("href", "mailto:support@dembrane.com");
fireEvent.click(screen.getByTestId("auth-login-blocked-use-code"));
expect(onUseCode).toHaveBeenCalledOnce();
});

it("leaves the code button out when there is no code route", () => {
show(<LoginBlockedNotice />);
expect(screen.queryByTestId("auth-login-blocked-use-code")).toBeNull();
});
});

describe("SignedInRecoveryNotice", () => {
it("says the person is logged in and lets them carry on", () => {
const onContinue = vi.fn();
show(<SignedInRecoveryNotice onContinue={onContinue} />);
expect(
screen.getByTestId("auth-login-signed-in-retry").textContent,
).toContain("You are logged in");
fireEvent.click(screen.getByTestId("auth-login-signed-in-continue"));
expect(onContinue).toHaveBeenCalledOnce();
});
});
54 changes: 54 additions & 0 deletions dembrane/frontend/src/components/auth/LoginRateLimitNotice.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { Trans } from "@lingui/react/macro";
import { Alert, Stack, Text } from "@mantine/core";
import { ClockIcon } from "@phosphor-icons/react";
import { testId } from "@/lib/testUtils";

/** 75 -> "1:15", 9 -> "0:09": a clock reads the same in every language. */
export function formatWait(seconds: number): string {
const s = Math.max(0, Math.ceil(seconds));
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
}

/**
* Says that sign-in is paused and counts down to when it opens again. The parent owns the
* countdown (useResendCooldown) and disables its own button while `seconds` is above zero.
* `flow` keeps the wording right: a password login never mentions a code.
*/
export const LoginRateLimitNotice = ({
seconds,
flow = "password",
}: {
seconds: number;
flow?: "password" | "code";
}) => {
const clock = formatWait(seconds);
return (
<Alert icon={<ClockIcon size={20} />} {...testId("auth-login-rate-limit")}>
<Stack gap={4}>
<Text size="sm">
{flow === "code" ? (
<Trans>
Too many tries. Wait a moment, then request a new code.
</Trans>
) : (
<Trans>
Too many login attempts. Wait a moment, then try again.
</Trans>
)}
</Text>
<Text
size="sm"
fw={500}
aria-live="polite"
{...testId("auth-login-rate-limit-wait")}
>
{seconds > 0 ? (
<Trans>You can try again in {clock}.</Trans>
) : (
<Trans>You can try again now.</Trans>
)}
</Text>
</Stack>
</Alert>
);
};
38 changes: 38 additions & 0 deletions dembrane/frontend/src/components/auth/SignedInRecoveryNotice.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { Trans } from "@lingui/react/macro";
import { Alert, Button, Stack, Text } from "@mantine/core";
import { InfoIcon } from "@phosphor-icons/react";
import { testId } from "@/lib/testUtils";

/**
* The sign-in worked, but a step after it (loading the active devices, the landing page)
* did not. The person is logged in; this says so and lets them carry on.
*/
export const SignedInRecoveryNotice = ({
onContinue,
loading = false,
}: {
onContinue: () => void;
loading?: boolean;
}) => (
<Alert
icon={<InfoIcon size={20} />}
{...testId("auth-login-signed-in-retry")}
>
<Stack gap="xs" align="flex-start">
<Text size="sm">
<Trans>
You are logged in, but we could not open dembrane just now. Try again
in a moment.
</Trans>
</Text>
<Button
size="xs"
loading={loading}
onClick={onContinue}
{...testId("auth-login-signed-in-continue")}
>
<Trans>Continue</Trans>
</Button>
</Stack>
</Alert>
);
10 changes: 8 additions & 2 deletions dembrane/frontend/src/components/auth/useResendCooldown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@ import { useEffect, useState } from "react";
// The API sends one code per email a minute.
export const RESEND_COOLDOWN_SECONDS = 60;

/** Seconds left before another code may be sent, and a `start` to begin the wait. */
/**
* Seconds left before another try, and a `start` to begin the wait: the default length, or
* the seconds a rate-limited answer named.
*/
export function useResendCooldown(seconds = RESEND_COOLDOWN_SECONDS) {
const [wait, setWait] = useState(0);

Expand All @@ -13,5 +16,8 @@ export function useResendCooldown(seconds = RESEND_COOLDOWN_SECONDS) {
return () => clearTimeout(id);
}, [wait]);

return { start: () => setWait(seconds), wait };
return {
start: (override?: number) => setWait(override ?? seconds),
wait,
};
}
Loading
Loading