diff --git a/web/app/login/page.tsx b/web/app/login/page.tsx new file mode 100644 index 0000000..da04bdd --- /dev/null +++ b/web/app/login/page.tsx @@ -0,0 +1,104 @@ +"use client"; + +import { useState } from "react"; +import { AuthCard, EmailField, LinkSentPanel } from "@/components/auth"; +import { Button } from "@/components/ui"; +import { useLogin } from "@/hooks/auth"; +import { isLikelyEmail } from "@/lib/auth/email"; +import { ApiError } from "@/lib/api"; + +export default function LoginPage() { + const [email, setEmail] = useState(""); + // fieldErr: client-side validation (shown inline on the input). + // apiErr: server / network failure (shown as a banner above the form). + const [fieldErr, setFieldErr] = useState(null); + const [apiErr, setApiErr] = useState(null); + const [sent, setSent] = useState(false); + const login = useLogin(); + + function onSubmit(e: React.FormEvent) { + e.preventDefault(); + setFieldErr(null); + setApiErr(null); + + // client-side gate — no API call when the address is malformed (parity with + // the legacy login.js: same regex, on the trimmed input). + if (!isLikelyEmail(email)) { + setFieldErr("E-mail inválido. Confira e tente de novo."); + return; + } + + login.mutate( + { email }, + { + onSuccess: () => setSent(true), + onError: (e) => { + if (e instanceof ApiError) { + const body = e.body as { error?: string } | undefined; + setApiErr(body?.error || "Erro ao enviar o link. Tente de novo."); + } else { + // fetch rejected (TypeError / abort) — no HTTP response. + setApiErr("Sem conexão. Verifique a internet e tente de novo."); + } + }, + } + ); + } + + return ( + + {sent ? ( + + ) : ( + <> + {apiErr ? ( + + ) : null} + +
+ { + setEmail(e.target.value); + if (fieldErr) setFieldErr(null); + }} + error={fieldErr ?? undefined} + hint="Você receberá um link de acesso válido por 15 minutos." + required + /> + + + +
+

+ Não tem conta?{" "} + + Solicitar convite → + +

+ + )} +
+ ); +} diff --git a/web/app/register/page.tsx b/web/app/register/page.tsx new file mode 100644 index 0000000..4386513 --- /dev/null +++ b/web/app/register/page.tsx @@ -0,0 +1,291 @@ +"use client"; + +import { Suspense, useState } from "react"; +import { useSearchParams } from "next/navigation"; +import { AuthCard, EmailField, LinkSentPanel } from "@/components/auth"; +import { Button, Field, TextField } from "@/components/ui"; +import { useRegister, useRequestInvite } from "@/hooks/auth"; +import { isLikelyEmail } from "@/lib/auth/email"; + +type Tab = "invite" | "waitlist"; + +function RegisterInner() { + const params = useSearchParams(); + const prefillInvite = params.get("invite") ?? ""; + const prefillEmail = params.get("email") ?? ""; + const linkExpired = params.get("error") === "link"; + // Both tabs default to "invite"; a prefilled invite/email simply lands here + // already (the invite tab is the default), with the fields populated below. + const [tab, setTab] = useState("invite"); + + return ( + + {linkExpired ? ( + + ) : null} + +
+ setTab("invite")}> + Tenho convite + + setTab("waitlist")} + > + Pedir convite + +
+ + {tab === "invite" ? ( + + ) : ( + + )} + +
+

+ Já tem conta?{" "} + + Entrar → + +

+
+ ); +} + +function TabButton({ + active, + onClick, + children, +}: { + active: boolean; + onClick: () => void; + children: React.ReactNode; +}) { + return ( + + ); +} + +function InvitePane({ + defaultInvite, + defaultEmail, +}: { + defaultInvite: string; + defaultEmail: string; +}) { + const [invite, setInvite] = useState(defaultInvite); + const [email, setEmail] = useState(defaultEmail); + const [fieldErr, setFieldErr] = useState(null); + const [apiErr, setApiErr] = useState(null); + const [sent, setSent] = useState(false); + const register = useRegister(); + + if (sent) { + return ( + + ); + } + + function onSubmit(e: React.FormEvent) { + e.preventDefault(); + setFieldErr(null); + setApiErr(null); + if (!invite.trim()) { + setFieldErr("Informe o código do convite."); + return; + } + if (!isLikelyEmail(email)) { + setFieldErr("E-mail inválido. Confira e tente de novo."); + return; + } + register.mutate( + { invite_code: invite, email }, + { + onSuccess: () => setSent(true), + onError: (err) => + setApiErr( + err instanceof Error && err.name === "ApiError" + ? "Convite inválido ou já usado. Confira o código." + : "Sem conexão. Verifique a internet e tente de novo." + ), + } + ); + } + + return ( + <> + {apiErr ? ( + + ) : null} +
+ { + setInvite(e.target.value); + if (fieldErr) setFieldErr(null); + }} + error={fieldErr && !invite.trim() ? fieldErr : undefined} + autoComplete="off" + required + /> + { + setEmail(e.target.value); + if (fieldErr) setFieldErr(null); + }} + error={fieldErr && invite.trim() ? fieldErr : undefined} + required + /> + + + + ); +} + +function WaitlistPane({ defaultEmail }: { defaultEmail: string }) { + const [email, setEmail] = useState(defaultEmail); + const [note, setNote] = useState(""); + const [fieldErr, setFieldErr] = useState(null); + const [apiErr, setApiErr] = useState(null); + const [sent, setSent] = useState(false); + const requestInvite = useRequestInvite(); + + if (sent) { + return ( + + ); + } + + function onSubmit(e: React.FormEvent) { + e.preventDefault(); + setFieldErr(null); + setApiErr(null); + if (!isLikelyEmail(email)) { + setFieldErr("E-mail inválido. Confira e tente de novo."); + return; + } + requestInvite.mutate( + { email, note }, + { + onSuccess: () => setSent(true), + onError: (err) => + setApiErr( + err instanceof Error && err.name === "ApiError" + ? "Não foi possível registrar o pedido. Tente de novo." + : "Sem conexão. Verifique a internet e tente de novo." + ), + } + ); + } + + return ( + <> + {apiErr ? ( + + ) : null} +
+ { + setEmail(e.target.value); + if (fieldErr) setFieldErr(null); + }} + error={fieldErr ?? undefined} + required + /> + setNote(e.target.value)} + rows={3} + /> + + + + ); +} + +export default function RegisterPage() { + return ( + }> + + + ); +} diff --git a/web/components/auth/AuthCard.tsx b/web/components/auth/AuthCard.tsx new file mode 100644 index 0000000..b3085c4 --- /dev/null +++ b/web/components/auth/AuthCard.tsx @@ -0,0 +1,72 @@ +"use client"; + +import type { ReactNode } from "react"; + +/** Centered, single-column shell for the public auth pages (login/register). + * Refined Editorial: warm bg, raised surface card, serif title. The brand + * links to the public site root (/) — a plain anchor so it escapes the /app + * basePath. */ +export function AuthCard({ + title, + subtitle, + children, +}: { + title: string; + subtitle?: ReactNode; + children?: ReactNode; +}) { + return ( +
+
+ {/* Brand links to the PUBLIC site root, outside the /app basePath, so a + plain anchor is intentional (a next/link Link would resolve to /app/). */} + {/* eslint-disable-next-line @next/next/no-html-link-for-pages */} + + + Zinom + + +
+

+ {title} +

+ {subtitle ? ( +

+ {subtitle} +

+ ) : null} +
{children}
+
+
+
+ ); +} + +function BrandMark() { + return ( + + ); +} diff --git a/web/components/auth/EmailField.tsx b/web/components/auth/EmailField.tsx new file mode 100644 index 0000000..f85338a --- /dev/null +++ b/web/components/auth/EmailField.tsx @@ -0,0 +1,20 @@ +"use client"; + +import { Field, type FieldProps } from "@/components/ui"; + +/** Thin wrapper over the design-system Field, pinned to type=email with the + * shared auth defaults (label, placeholder, autocomplete). Validation is the + * page's job (client-side, before any API call). */ +export function EmailField(props: FieldProps) { + return ( + + ); +} diff --git a/web/components/auth/LinkSentPanel.tsx b/web/components/auth/LinkSentPanel.tsx new file mode 100644 index 0000000..98f7a98 --- /dev/null +++ b/web/components/auth/LinkSentPanel.tsx @@ -0,0 +1,38 @@ +"use client"; + +/** Success panel shown after a magic-link request (login) or a successful + * invite register. Generic copy — it must not reveal whether the e-mail has + * an account. */ +export function LinkSentPanel({ + title = "Link enviado", + message = "Verifique sua caixa de entrada. Se o e-mail tiver uma conta, o link chegará em instantes.", + id, +}: { + title?: string; + message?: string; + id?: string; +}) { + return ( +
+ + + +

{title}

+

{message}

+
+ ); +} diff --git a/web/components/auth/index.ts b/web/components/auth/index.ts new file mode 100644 index 0000000..dff237b --- /dev/null +++ b/web/components/auth/index.ts @@ -0,0 +1,3 @@ +export * from "./AuthCard"; +export * from "./EmailField"; +export * from "./LinkSentPanel"; diff --git a/web/components/protected.tsx b/web/components/protected.tsx index 2574814..d1e4987 100644 --- a/web/components/protected.tsx +++ b/web/components/protected.tsx @@ -7,7 +7,7 @@ import { apiFetch, UnauthorizedError, onUnauthorized } from "@/lib/api"; export function Protected({ children }: { children: React.ReactNode }) { useEffect(() => { onUnauthorized(() => { - window.location.assign("/login"); + window.location.assign("/app/login/"); }); }, []); diff --git a/web/e2e/login.spec.ts b/web/e2e/login.spec.ts new file mode 100644 index 0000000..1b0c9d1 --- /dev/null +++ b/web/e2e/login.spec.ts @@ -0,0 +1,166 @@ +import { test, expect, type Page, type Route } from "@playwright/test"; + +// Public auth routes (SP1): /app/login/ + /app/register/. No Protected guard, +// so these pages render without /portal/me. We mock the portal POST endpoints. + +function json(body: unknown, status = 200) { + return { + status, + contentType: "application/json", + body: JSON.stringify(body), + }; +} + +interface PortalMocks { + login?: (r: Route) => void; + register?: (r: Route) => void; + requestInvite?: (r: Route) => void; +} + +async function mockPortal(page: Page, m: PortalMocks = {}) { + await page.route("**/portal/login", m.login ?? ((r) => r.fulfill(json({ ok: true })))); + await page.route( + "**/portal/register", + m.register ?? ((r) => r.fulfill(json({ ok: true }))) + ); + await page.route( + "**/portal/request-invite", + m.requestInvite ?? ((r) => r.fulfill(json({ ok: true }))) + ); +} + +// --------------------------------------------------------------------------- +// Login +// --------------------------------------------------------------------------- + +test("login: client validation blocks an invalid email (no request)", async ({ + page, +}) => { + let called = false; + await mockPortal(page, { + login: (r) => { + called = true; + r.fulfill(json({ ok: true })); + }, + }); + await page.goto("/app/login/"); + + await page.locator("#login-email").fill("not-an-email"); + await page.getByTestId("login-submit").click(); + + // inline field error, no success panel, and crucially no POST fired + await expect(page.locator("#login-sent")).toHaveCount(0); + await expect(page.locator("#login-email-err")).toContainText( + "E-mail inválido" + ); + expect(called).toBe(false); +}); + +test("login: a valid email shows the \"Link enviado\" panel", async ({ page }) => { + await mockPortal(page); + await page.goto("/app/login/"); + + await page.locator("#login-email").fill("voce@email.com"); + await page.getByTestId("login-submit").click(); + + await expect(page.locator("#login-sent")).toBeVisible(); + await expect(page.locator("#login-sent")).toContainText("Link enviado"); +}); + +test("login: a network failure shows \"Sem conexão\"", async ({ page }) => { + await mockPortal(page, { login: (r) => r.abort("failed") }); + await page.goto("/app/login/"); + + await page.locator("#login-email").fill("voce@email.com"); + await page.getByTestId("login-submit").click(); + + await expect(page.locator("#login-err")).toBeVisible(); + await expect(page.locator("#login-err")).toContainText("Sem conexão"); +}); + +test("login: link to register points at /app/register/", async ({ page }) => { + await mockPortal(page); + await page.goto("/app/login/"); + await expect( + page.getByRole("link", { name: /Solicitar convite/ }) + ).toHaveAttribute("href", "/app/register/"); +}); + +// --------------------------------------------------------------------------- +// Register +// --------------------------------------------------------------------------- + +test("register: invite happy path shows #sent", async ({ page }) => { + await mockPortal(page); + await page.goto("/app/register/"); + + await page.locator("#invite").fill("zin_abc123"); + await page.locator("#reg-email").fill("voce@email.com"); + await page.getByTestId("register-submit").click(); + + await expect(page.locator("#register-sent")).toBeVisible(); + await expect(page.locator("#register-sent")).toContainText("Conta criada"); +}); + +test("register: ?invite + ?email prefill the fields and activate the invite tab", async ({ + page, +}) => { + await mockPortal(page); + await page.goto("/app/register/?invite=zin_xyz&email=alguem%40email.com"); + + await expect(page.locator("#invite")).toHaveValue("zin_xyz"); + await expect(page.locator("#reg-email")).toHaveValue("alguem@email.com"); + // invite tab is the active one + await expect( + page.getByRole("tab", { name: "Tenho convite" }) + ).toHaveAttribute("aria-selected", "true"); +}); + +test("register: ?error=link shows the expired-link notice", async ({ page }) => { + await mockPortal(page); + await page.goto("/app/register/?error=link"); + + await expect(page.locator("#register-link-expired")).toBeVisible(); + await expect(page.locator("#register-link-expired")).toContainText( + "expirou" + ); +}); + +test("register: waitlist tab requests an invite", async ({ page }) => { + await mockPortal(page); + await page.goto("/app/register/"); + + await page.getByRole("tab", { name: "Pedir convite" }).click(); + await page.locator("#req-email").fill("voce@email.com"); + await page.locator("#req-note").fill("Quero organizar minhas reuniões."); + await page.getByTestId("waitlist-submit").click(); + + await expect(page.locator("#waitlist-sent")).toBeVisible(); + await expect(page.locator("#waitlist-sent")).toContainText("Pedido recebido"); +}); + +// --------------------------------------------------------------------------- +// No horizontal overflow at narrow + wide viewports +// --------------------------------------------------------------------------- + +const WIDTHS = [360, 1440]; + +for (const path of ["/app/login/", "/app/register/"]) { + for (const width of WIDTHS) { + test(`no horizontal overflow at ${width}px on ${path}`, async ({ page }) => { + await page.setViewportSize({ width, height: 900 }); + await mockPortal(page); + await page.goto(path); + await expect(page.locator("main")).toBeVisible(); + + const result = await page.evaluate(() => { + const w = window.innerWidth; + return { + innerWidth: w, + scrollWidth: document.documentElement.scrollWidth, + }; + }); + expect(result.scrollWidth).toBeLessThanOrEqual(result.innerWidth); + }); + } +} diff --git a/web/hooks/auth.test.tsx b/web/hooks/auth.test.tsx new file mode 100644 index 0000000..da2a212 --- /dev/null +++ b/web/hooks/auth.test.tsx @@ -0,0 +1,76 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { renderHook, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { ReactNode } from "react"; +import { createElement } from "react"; +import * as api from "@/lib/api"; +import { useLogin, useRegister, useRequestInvite } from "./auth"; + +function wrapper() { + const qc = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); + return ({ children }: { children: ReactNode }) => + createElement(QueryClientProvider, { client: qc }, children); +} + +afterEach(() => vi.restoreAllMocks()); + +describe("useLogin", () => { + it("POSTs the trimmed email to /portal/login and resolves {ok}", async () => { + const spy = vi.spyOn(api, "apiFetch").mockResolvedValue({ ok: true }); + const { result } = renderHook(() => useLogin(), { wrapper: wrapper() }); + + result.current.mutate({ email: " voce@email.com " }); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(spy).toHaveBeenCalledWith("/portal/login", { + method: "POST", + body: { email: "voce@email.com" }, + }); + expect(result.current.data).toEqual({ ok: true }); + }); + + it("surfaces an ApiError (e.g. 400 {error}) as a mutation error", async () => { + vi.spyOn(api, "apiFetch").mockRejectedValue( + new api.ApiError(400, { error: "bad" }) + ); + const { result } = renderHook(() => useLogin(), { wrapper: wrapper() }); + + result.current.mutate({ email: "voce@email.com" }); + await waitFor(() => expect(result.current.isError).toBe(true)); + expect(result.current.error).toBeInstanceOf(api.ApiError); + }); +}); + +describe("useRegister", () => { + it("POSTs trimmed invite_code + email to /portal/register", async () => { + const spy = vi.spyOn(api, "apiFetch").mockResolvedValue({ ok: true }); + const { result } = renderHook(() => useRegister(), { wrapper: wrapper() }); + + result.current.mutate({ invite_code: " ABC123 ", email: " a@b.com " }); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(spy).toHaveBeenCalledWith("/portal/register", { + method: "POST", + body: { invite_code: "ABC123", email: "a@b.com" }, + }); + }); +}); + +describe("useRequestInvite", () => { + it("POSTs trimmed email + note to /portal/request-invite", async () => { + const spy = vi.spyOn(api, "apiFetch").mockResolvedValue({ ok: true }); + const { result } = renderHook(() => useRequestInvite(), { + wrapper: wrapper(), + }); + + result.current.mutate({ email: " a@b.com ", note: " oi " }); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(spy).toHaveBeenCalledWith("/portal/request-invite", { + method: "POST", + body: { email: "a@b.com", note: "oi" }, + }); + }); +}); diff --git a/web/hooks/auth.ts b/web/hooks/auth.ts new file mode 100644 index 0000000..575a1a6 --- /dev/null +++ b/web/hooks/auth.ts @@ -0,0 +1,66 @@ +"use client"; + +// React Query mutations for the public auth pages (SP1): magic-link login, +// invite-gated register, and waitlist request-invite. These run on the public +// /app/login/ and /app/register/ routes (no Protected guard), so they only ever +// POST to the portal — no session query here. + +import { useMutation, type UseMutationResult } from "@tanstack/react-query"; +import { apiFetch } from "@/lib/api"; +import type { OkResponse } from "@/lib/contracts"; + +export interface LoginVars { + email: string; +} + +/** POST /portal/login {email} → {ok}. Generic success (never reveals whether + * the e-mail has an account); a 400 surfaces {error} via ApiError. */ +export function useLogin(): UseMutationResult { + return useMutation({ + mutationFn: ({ email }) => + apiFetch("/portal/login", { + method: "POST", + body: { email: email.trim() }, + }), + }); +} + +export interface RegisterVars { + invite_code: string; + email: string; +} + +/** POST /portal/register {invite_code,email} → {ok}. */ +export function useRegister(): UseMutationResult< + OkResponse, + unknown, + RegisterVars +> { + return useMutation({ + mutationFn: ({ invite_code, email }) => + apiFetch("/portal/register", { + method: "POST", + body: { invite_code: invite_code.trim(), email: email.trim() }, + }), + }); +} + +export interface RequestInviteVars { + email: string; + note: string; +} + +/** POST /portal/request-invite {email,note} → {ok} (waitlist). */ +export function useRequestInvite(): UseMutationResult< + OkResponse, + unknown, + RequestInviteVars +> { + return useMutation({ + mutationFn: ({ email, note }) => + apiFetch("/portal/request-invite", { + method: "POST", + body: { email: email.trim(), note: note.trim() }, + }), + }); +} diff --git a/web/lib/api.ts b/web/lib/api.ts index fc134f4..ed57209 100644 --- a/web/lib/api.ts +++ b/web/lib/api.ts @@ -27,7 +27,7 @@ type UnauthorizedHandler = () => void; let unauthorizedHandler: UnauthorizedHandler = () => { if (typeof window !== "undefined") { - window.location.assign("/login"); + window.location.assign("/app/login/"); } }; diff --git a/web/lib/auth/email.test.ts b/web/lib/auth/email.test.ts new file mode 100644 index 0000000..35ad5ad --- /dev/null +++ b/web/lib/auth/email.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; +import { isLikelyEmail } from "./email"; + +describe("isLikelyEmail", () => { + it("accepts a normal address", () => { + expect(isLikelyEmail("voce@email.com")).toBe(true); + expect(isLikelyEmail("a.b-c+tag@sub.example.com.br")).toBe(true); + }); + + it("trims surrounding whitespace before validating", () => { + expect(isLikelyEmail(" voce@email.com ")).toBe(true); + }); + + it("rejects empty / malformed input", () => { + expect(isLikelyEmail("")).toBe(false); + expect(isLikelyEmail(" ")).toBe(false); + expect(isLikelyEmail("voce")).toBe(false); + expect(isLikelyEmail("voce@")).toBe(false); + expect(isLikelyEmail("voce@email")).toBe(false); + expect(isLikelyEmail("@email.com")).toBe(false); + expect(isLikelyEmail("a b@email.com")).toBe(false); + expect(isLikelyEmail("voce@@email.com")).toBe(false); + }); +}); diff --git a/web/lib/auth/email.ts b/web/lib/auth/email.ts new file mode 100644 index 0000000..cd39619 --- /dev/null +++ b/web/lib/auth/email.ts @@ -0,0 +1,11 @@ +// Client-side e-mail validation for the auth pages (SP1). +// +// The regex is verbatim from the legacy portal `login.js` so the Next pages +// reject exactly the same inputs as the vanilla page did: +// /^[^\s@]+@[^\s@]+\.[^\s@]+$/ on the TRIMMED input. +const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +/** True when `value` (after trimming) looks like an e-mail address. */ +export function isLikelyEmail(value: string): boolean { + return EMAIL_RE.test(value.trim()); +}