* feat(auth): add email verification login flow with 401 auto-redirect Replace the old OAuth-based login with email verification codes: - Backend: send-code / verify-code endpoints, verification_codes table (migration 009), rate limiting, Resend email service - Frontend: two-step login UI (email → 6-digit OTP), auth store with sendCode/verifyCode - SDK: ApiClient gains onUnauthorized callback; 401 responses auto-clear token and redirect to /login - Fix login button staying disabled due to global isLoading state Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(auth): add brute-force protection, redirect loop guard, and expired code cleanup - VerifyCode: increment attempts on wrong code, reject after 5 failed tries (migration 010) - onUnauthorized: skip redirect if already on /login to prevent infinite loops - SendCode: best-effort cleanup of expired verification codes older than 1 hour Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(auth): add master verification code for non-production environments Allow code "888888" to bypass email verification in non-production environments to simplify development and testing workflows. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(auth): add personal access tokens for CLI and API authentication Add full-stack PAT support: users create tokens in Settings, CLI authenticates via `multica auth login`. Server stores SHA-256 hashes only. Auth middleware extended to accept both JWTs and PATs (distinguished by `mul_` prefix). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
125 lines
3.6 KiB
TypeScript
125 lines
3.6 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
import { render, screen, waitFor } from "@testing-library/react";
|
|
import userEvent from "@testing-library/user-event";
|
|
|
|
// Mock next/navigation
|
|
vi.mock("next/navigation", () => ({
|
|
useRouter: () => ({ push: vi.fn() }),
|
|
usePathname: () => "/login",
|
|
useSearchParams: () => new URLSearchParams(),
|
|
}));
|
|
|
|
// Mock auth store
|
|
const mockSendCode = vi.fn();
|
|
const mockVerifyCode = vi.fn();
|
|
vi.mock("@/features/auth", () => ({
|
|
useAuthStore: (selector: (s: any) => any) =>
|
|
selector({
|
|
sendCode: mockSendCode,
|
|
verifyCode: mockVerifyCode,
|
|
isLoading: false,
|
|
}),
|
|
}));
|
|
|
|
// Mock workspace store
|
|
const mockHydrateWorkspace = vi.fn();
|
|
vi.mock("@/features/workspace", () => ({
|
|
useWorkspaceStore: (selector: (s: any) => any) =>
|
|
selector({
|
|
hydrateWorkspace: mockHydrateWorkspace,
|
|
}),
|
|
}));
|
|
|
|
// Mock api
|
|
vi.mock("@/shared/api", () => ({
|
|
api: {
|
|
listWorkspaces: vi.fn().mockResolvedValue([]),
|
|
},
|
|
}));
|
|
|
|
import LoginPage from "./page";
|
|
|
|
describe("LoginPage", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
it("renders email form with heading and button", () => {
|
|
render(<LoginPage />);
|
|
|
|
expect(screen.getByText("Multica")).toBeInTheDocument();
|
|
expect(screen.getByText("AI-native task management")).toBeInTheDocument();
|
|
expect(screen.getByLabelText("Email")).toBeInTheDocument();
|
|
expect(
|
|
screen.getByRole("button", { name: /continue/i })
|
|
).toBeInTheDocument();
|
|
});
|
|
|
|
it("does not call sendCode when email is empty", async () => {
|
|
const user = userEvent.setup();
|
|
render(<LoginPage />);
|
|
|
|
await user.click(screen.getByRole("button", { name: /continue/i }));
|
|
expect(mockSendCode).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("calls sendCode on submit and shows code step", async () => {
|
|
mockSendCode.mockResolvedValueOnce(undefined);
|
|
const user = userEvent.setup();
|
|
render(<LoginPage />);
|
|
|
|
await user.type(screen.getByLabelText("Email"), "test@multica.ai");
|
|
await user.click(screen.getByRole("button", { name: /continue/i }));
|
|
|
|
await waitFor(() => {
|
|
expect(mockSendCode).toHaveBeenCalledWith("test@multica.ai");
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText("Check your email")).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it("shows 'Sending code...' while submitting", async () => {
|
|
mockSendCode.mockReturnValueOnce(new Promise(() => {}));
|
|
const user = userEvent.setup();
|
|
render(<LoginPage />);
|
|
|
|
await user.type(screen.getByLabelText("Email"), "test@multica.ai");
|
|
await user.click(screen.getByRole("button", { name: /continue/i }));
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText("Sending code...")).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it("shows error when sendCode fails", async () => {
|
|
mockSendCode.mockRejectedValueOnce(new Error("Network error"));
|
|
const user = userEvent.setup();
|
|
render(<LoginPage />);
|
|
|
|
await user.type(screen.getByLabelText("Email"), "test@multica.ai");
|
|
await user.click(screen.getByRole("button", { name: /continue/i }));
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText("Network error")).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it("shows back button on code step", async () => {
|
|
mockSendCode.mockResolvedValueOnce(undefined);
|
|
const user = userEvent.setup();
|
|
render(<LoginPage />);
|
|
|
|
await user.type(screen.getByLabelText("Email"), "test@multica.ai");
|
|
await user.click(screen.getByRole("button", { name: /continue/i }));
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText("Check your email")).toBeInTheDocument();
|
|
});
|
|
|
|
expect(
|
|
screen.getByRole("button", { name: /back/i })
|
|
).toBeInTheDocument();
|
|
});
|
|
});
|