diff --git a/.env.example b/.env.example
index 312ec87a..1c0c93ab 100644
--- a/.env.example
+++ b/.env.example
@@ -21,6 +21,10 @@ MULTICA_CODEX_MODEL=
MULTICA_CODEX_WORKDIR=
MULTICA_CODEX_TIMEOUT=20m
+# Email (Resend)
+RESEND_API_KEY=
+RESEND_FROM_EMAIL=noreply@multica.ai
+
# Google OAuth
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
diff --git a/CLAUDE.md b/CLAUDE.md
index f0e09605..179e4728 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -16,7 +16,7 @@ Multica is an AI-native task management platform — like Linear, but with AI ag
- `server/` — Go backend (Chi router, sqlc for DB, gorilla/websocket for real-time)
- `apps/web/` — Next.js 16 frontend (App Router)
-- `packages/` — Shared TypeScript packages (ui, types, sdk, store, hooks, utils)
+- `packages/` — Shared TypeScript packages (ui, types, sdk, utils)
### Web App Structure (`apps/web/`)
@@ -40,6 +40,8 @@ apps/web/
| `features/issues/` | Issue state, components, config | `useIssueStore`, icons, pickers, status/priority config |
| `features/inbox/` | Inbox notification state | `useInboxStore` |
| `features/realtime/` | WebSocket connection + sync | `WSProvider`, `useWSEvent`, `useRealtimeSync` |
+| `features/modals/` | Modal registry and state | Modal store and components |
+| `features/skills/` | Skill management | Skill components |
**`shared/`** — Code used across multiple features. Currently only `api.ts` (SDK singleton).
@@ -88,6 +90,7 @@ Browser ← WSClient (SDK) ← WebSocket ← Hub.Broadcast() ← Handlers/TaskSe
- **Agent SDK** (`pkg/agent/`): Unified `Backend` interface for executing prompts via Claude Code or Codex. Each backend spawns its CLI and streams results via `Session.Messages` + `Session.Result` channels.
- **Daemon** (`internal/daemon/`): Local agent runtime — auto-detects available CLIs (claude, codex), registers runtimes, polls for tasks, routes by provider.
- **CLI** (`internal/cli/`): Shared helpers for the `multica` CLI — API client, config management, output formatting.
+- **Events** (`internal/events/`): Internal event bus for decoupled communication between handlers and services.
- **Database**: sqlc generates Go code from SQL in `pkg/db/queries/` → `pkg/db/generated/`. Migrations in `migrations/`.
- **Routes** (`cmd/server/router.go`): Public routes (auth, health, ws) + protected routes (require JWT) + daemon routes (unauthenticated, separate auth model).
@@ -96,6 +99,7 @@ Browser ← WSClient (SDK) ← WebSocket ← Hub.Broadcast() ← Handlers/TaskSe
- **`@multica/sdk`**: `ApiClient` (REST) and `WSClient` (WebSocket) classes. All backend communication goes through here.
- **`@multica/types`**: Shared domain types + WebSocket event types (issue:created/updated/deleted, task:*, agent:status, comment:*, inbox:new, daemon:*).
- **`@multica/ui`**: shadcn/ui component library with Radix primitives, Tailwind CSS 4, Shiki syntax highlighting for markdown.
+- **`@multica/utils`**: Shared utility functions used across apps and packages.
### Multi-tenancy
@@ -145,7 +149,8 @@ make db-down # Stop shared PostgreSQL
### Worktree Support
-For isolated feature testing with a separate database:
+All checkouts share one PostgreSQL container. Isolation is at the database level — each worktree gets its own DB name and unique ports via `.env.worktree`. Main checkouts use `.env`.
+
```bash
make worktree-env # Generate .env.worktree with unique DB/ports
make setup-worktree # Setup using .env.worktree
diff --git a/apps/web/app/(auth)/login/page.test.tsx b/apps/web/app/(auth)/login/page.test.tsx
index e69555d4..50152d87 100644
--- a/apps/web/app/(auth)/login/page.test.tsx
+++ b/apps/web/app/(auth)/login/page.test.tsx
@@ -10,11 +10,13 @@ vi.mock("next/navigation", () => ({
}));
// Mock auth store
-const mockLogin = vi.fn();
+const mockSendCode = vi.fn();
+const mockVerifyCode = vi.fn();
vi.mock("@/features/auth", () => ({
useAuthStore: (selector: (s: any) => any) =>
selector({
- login: mockLogin,
+ sendCode: mockSendCode,
+ verifyCode: mockVerifyCode,
isLoading: false,
}),
}));
@@ -42,78 +44,82 @@ describe("LoginPage", () => {
vi.clearAllMocks();
});
- it("renders login form with heading, inputs, and button", () => {
+ it("renders email form with heading and button", () => {
render();
expect(screen.getByText("Multica")).toBeInTheDocument();
expect(screen.getByText("AI-native task management")).toBeInTheDocument();
expect(screen.getByLabelText("Email")).toBeInTheDocument();
- expect(screen.getByLabelText("Name")).toBeInTheDocument();
- expect(screen.getByRole("button", { name: /sign in/i })).toBeInTheDocument();
+ expect(
+ screen.getByRole("button", { name: /continue/i })
+ ).toBeInTheDocument();
});
- it("does not call login when email is empty", async () => {
+ it("does not call sendCode when email is empty", async () => {
const user = userEvent.setup();
render();
- await user.click(screen.getByRole("button", { name: "Sign in" }));
- expect(mockLogin).not.toHaveBeenCalled();
+ await user.click(screen.getByRole("button", { name: /continue/i }));
+ expect(mockSendCode).not.toHaveBeenCalled();
});
- it("calls login with correct args on submit", async () => {
- mockLogin.mockResolvedValueOnce({ id: "u1", name: "Test User" });
- mockHydrateWorkspace.mockResolvedValueOnce(null);
+ it("calls sendCode on submit and shows code step", async () => {
+ mockSendCode.mockResolvedValueOnce(undefined);
const user = userEvent.setup();
render();
await user.type(screen.getByLabelText("Email"), "test@multica.ai");
- await user.type(screen.getByLabelText("Name"), "Test User");
- await user.click(screen.getByRole("button", { name: "Sign in" }));
+ await user.click(screen.getByRole("button", { name: /continue/i }));
await waitFor(() => {
- expect(mockLogin).toHaveBeenCalledWith("test@multica.ai", "Test User");
+ expect(mockSendCode).toHaveBeenCalledWith("test@multica.ai");
+ });
+
+ await waitFor(() => {
+ expect(screen.getByText("Check your email")).toBeInTheDocument();
});
});
- it("calls login with email only when name is empty", async () => {
- mockLogin.mockResolvedValueOnce({ id: "u1", name: "" });
- mockHydrateWorkspace.mockResolvedValueOnce(null);
+ it("shows 'Sending code...' while submitting", async () => {
+ mockSendCode.mockReturnValueOnce(new Promise(() => {}));
const user = userEvent.setup();
render();
await user.type(screen.getByLabelText("Email"), "test@multica.ai");
- await user.click(screen.getByRole("button", { name: "Sign in" }));
+ await user.click(screen.getByRole("button", { name: /continue/i }));
await waitFor(() => {
- expect(mockLogin).toHaveBeenCalledWith("test@multica.ai", undefined);
+ expect(screen.getByText("Sending code...")).toBeInTheDocument();
});
});
- it("shows 'Signing in...' while submitting", async () => {
- mockLogin.mockReturnValueOnce(new Promise(() => {}));
+ it("shows error when sendCode fails", async () => {
+ mockSendCode.mockRejectedValueOnce(new Error("Network error"));
const user = userEvent.setup();
render();
await user.type(screen.getByLabelText("Email"), "test@multica.ai");
- await user.click(screen.getByRole("button", { name: "Sign in" }));
+ await user.click(screen.getByRole("button", { name: /continue/i }));
await waitFor(() => {
- expect(screen.getByText("Signing in...")).toBeInTheDocument();
+ expect(screen.getByText("Network error")).toBeInTheDocument();
});
});
- it("shows error when login fails", async () => {
- mockLogin.mockRejectedValueOnce(new Error("Network error"));
+ it("shows back button on code step", async () => {
+ mockSendCode.mockResolvedValueOnce(undefined);
const user = userEvent.setup();
render();
await user.type(screen.getByLabelText("Email"), "test@multica.ai");
- await user.click(screen.getByRole("button", { name: "Sign in" }));
+ await user.click(screen.getByRole("button", { name: /continue/i }));
await waitFor(() => {
- expect(
- screen.getByText("Login failed. Make sure the server is running."),
- ).toBeInTheDocument();
+ expect(screen.getByText("Check your email")).toBeInTheDocument();
});
+
+ expect(
+ screen.getByRole("button", { name: /back/i })
+ ).toBeInTheDocument();
});
});
diff --git a/apps/web/app/(auth)/login/page.tsx b/apps/web/app/(auth)/login/page.tsx
index b1c2cee2..d7e45010 100644
--- a/apps/web/app/(auth)/login/page.tsx
+++ b/apps/web/app/(auth)/login/page.tsx
@@ -1,10 +1,9 @@
"use client";
-import { Suspense, useState } from "react";
+import { Suspense, useState, useEffect, useCallback } from "react";
import { useSearchParams, useRouter } from "next/navigation";
import { useAuthStore } from "@/features/auth";
import { useWorkspaceStore } from "@/features/workspace";
-import { useNavigationStore } from "@/features/navigation";
import { api } from "@/shared/api";
import {
Card,
@@ -17,20 +16,34 @@ import {
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
+import {
+ InputOTP,
+ InputOTPGroup,
+ InputOTPSlot,
+} from "@/components/ui/input-otp";
function LoginPageContent() {
const router = useRouter();
- const login = useAuthStore((s) => s.login);
- const isLoading = useAuthStore((s) => s.isLoading);
+ const sendCode = useAuthStore((s) => s.sendCode);
+ const verifyCode = useAuthStore((s) => s.verifyCode);
const hydrateWorkspace = useWorkspaceStore((s) => s.hydrateWorkspace);
const searchParams = useSearchParams();
+
+ const [step, setStep] = useState<"email" | "code">("email");
const [email, setEmail] = useState("");
- const [name, setName] = useState("");
+ const [code, setCode] = useState("");
const [error, setError] = useState("");
const [submitting, setSubmitting] = useState(false);
+ const [cooldown, setCooldown] = useState(0);
- const handleSubmit = async (e: React.FormEvent) => {
- e.preventDefault();
+ useEffect(() => {
+ if (cooldown <= 0) return;
+ const timer = setTimeout(() => setCooldown((c) => c - 1), 1000);
+ return () => clearTimeout(timer);
+ }, [cooldown]);
+
+ const handleSendCode = async (e?: React.FormEvent) => {
+ e?.preventDefault();
if (!email) {
setError("Email is required");
return;
@@ -38,17 +51,115 @@ function LoginPageContent() {
setError("");
setSubmitting(true);
try {
- await login(email, name || undefined);
- const wsList = await api.listWorkspaces();
- await hydrateWorkspace(wsList);
- const fallback = useNavigationStore.getState().lastPath;
- router.push(searchParams.get("next") || fallback);
+ await sendCode(email);
+ setStep("code");
+ setCode("");
+ setCooldown(60);
} catch (err) {
- setError("Login failed. Make sure the server is running.");
+ setError(
+ err instanceof Error ? err.message : "Failed to send code. Make sure the server is running."
+ );
+ } finally {
setSubmitting(false);
}
};
+ const handleVerifyCode = useCallback(
+ async (value: string) => {
+ if (value.length !== 6) return;
+ setError("");
+ setSubmitting(true);
+ try {
+ await verifyCode(email, value);
+ const wsList = await api.listWorkspaces();
+ await hydrateWorkspace(wsList);
+ router.push(searchParams.get("next") || "/issues");
+ } catch (err) {
+ setError(
+ err instanceof Error ? err.message : "Invalid or expired code"
+ );
+ setCode("");
+ setSubmitting(false);
+ }
+ },
+ [email, verifyCode, hydrateWorkspace, router, searchParams]
+ );
+
+ const handleResend = async () => {
+ if (cooldown > 0) return;
+ setError("");
+ try {
+ await sendCode(email);
+ setCooldown(60);
+ } catch (err) {
+ setError(
+ err instanceof Error ? err.message : "Failed to resend code"
+ );
+ }
+ };
+
+ if (step === "code") {
+ return (
+
+
+
+ Check your email
+
+ We sent a verification code to{" "}
+ {email}
+
+
+
+ {
+ setCode(value);
+ if (value.length === 6) handleVerifyCode(value);
+ }}
+ disabled={submitting}
+ >
+
+
+
+
+
+
+
+
+
+ {error && (
+ {error}
+ )}
+
+
+
+
+
+
+
+
+
+ );
+ }
+
return (
@@ -57,17 +168,7 @@ function LoginPageContent() {
AI-native task management
-