diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..b460e88
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,19 @@
+# ---------------------------------------------------------------------------
+# honeyDue Web — environment variables
+# ---------------------------------------------------------------------------
+# Copy to `.env.local` and fill in. `.env*` is gitignored.
+
+# honeyDue Go API base URL (client-side, used by the Next.js proxy).
+NEXT_PUBLIC_API_URL=https://honeyDue.treytartt.com/api
+
+# honeyDue Go API base URL (server-side; falls back to NEXT_PUBLIC_API_URL).
+# API_URL=https://honeyDue.treytartt.com/api
+
+# Ory Kratos public API base URL. Identity (login, registration, recovery,
+# verification, settings, social sign-in) is owned by Kratos. The browser
+# talks to Kratos self-service flows directly.
+NEXT_PUBLIC_KRATOS_URL=https://auth.myhoneydue.com
+
+# PostHog analytics (optional).
+# NEXT_PUBLIC_POSTHOG_KEY=
+# NEXT_PUBLIC_POSTHOG_HOST=https://analytics.88oakapps.com
diff --git a/CLAUDE.md b/CLAUDE.md
index 0b8b92d..2fa20ac 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -32,17 +32,27 @@ npm run analyze # Bundle analysis
```
Browser → Next.js page (client component)
→ apiFetch("/tasks/") → /api/proxy/tasks (Next.js route handler)
- → Go API (reads honeydue-token httpOnly cookie, forwards as Authorization header)
+ → Go API (reads ory_kratos_session cookie, forwards it as a Cookie header)
```
-Auth tokens are stored as httpOnly cookies (`honeydue-token`), never exposed to JS. The Next.js `/api/proxy/[...path]` catch-all route forwards requests to the Go API.
+Identity is owned by **Ory Kratos** (`NEXT_PUBLIC_KRATOS_URL`). The browser holds an `ory_kratos_session` cookie set by Kratos. The Next.js `/api/proxy/[...path]` catch-all route forwards that cookie to the Go API, which validates the session against Kratos. The Go API no longer does auth (no `Authorization: Token`).
+
+### Auth (Ory Kratos)
+
+Login / registration / recovery (forgot-password) / email verification / password changes use **Kratos browser self-service flows**:
+
+- Auth pages (`src/app/(auth)/...`) initialize a flow by hard-navigating the browser to `{kratos}/self-service/{type}/browser`; Kratos sets a flow cookie and redirects back with `?flow=`.
+- The page reads `?flow=`, fetches the flow definition (`ui.nodes` / `ui.action` / `ui.method`), and renders it generically via ``.
+- Social sign-in (Apple/Google) = the `oidc` nodes in the flow.
+- `src/lib/kratos/` holds the client (`getFlow`, `submitFlow`, `whoami`, `logout`, ...) and the `useKratosFlow` hook.
+- Route protection: `` (in `src/app/app/layout.tsx`) calls `{kratos}/sessions/whoami`; the middleware does a cheap `ory_kratos_session` cookie pre-filter.
### Directory Structure
```
src/
├── app/
-│ ├── (auth)/ # Login, register, forgot-password (public)
+│ ├── (auth)/ # Kratos self-service flow pages (login/register/recovery/verify)
│ ├── api/proxy/ # Catch-all proxy to Go API
│ ├── app/ # Authenticated app pages
│ │ ├── contractors/ # Contractor CRUD
@@ -103,7 +113,7 @@ src/
**Kanban boards**: Tasks display in kanban columns (overdue, due_soon, in_progress, not_started, completed). Uses `@dnd-kit` for drag-and-drop. Column names match Go API: `overdue_tasks`, `due_soon_tasks`, `in_progress_tasks`, `not_started_tasks`, `completed_tasks`.
-**Middleware** (`src/middleware.ts`): Checks `honeydue-token` cookie. Redirects unauthenticated users to `/login` for protected routes. Skips API routes, static files, and public paths.
+**Middleware** (`src/middleware.ts`): Cheap pre-filter on the `ory_kratos_session` cookie. Redirects users without the cookie to `/login` for protected routes. Skips API routes, static files, and public paths. The authoritative session check is `` (`whoami`).
## Conventions
@@ -125,9 +135,12 @@ src/
|----------|-------------|---------|
| `NEXT_PUBLIC_API_URL` | Go API URL (client-side) | `https://honeyDue.treytartt.com/api` |
| `API_URL` | Go API URL (server-side, no proxy) | Falls back to `NEXT_PUBLIC_API_URL` |
+| `NEXT_PUBLIC_KRATOS_URL` | Ory Kratos public API URL (identity) | `https://auth.myhoneydue.com` |
| `NEXT_PUBLIC_POSTHOG_KEY` | PostHog analytics key | — |
| `NEXT_PUBLIC_POSTHOG_HOST` | PostHog host | — |
+See `.env.example` for the full list.
+
## Common Tasks
**Add a new page**: Create `src/app/app/{route}/page.tsx` (client component). Add nav item to `src/components/layout/nav-items.ts`.
diff --git a/src/app/(auth)/forgot-password/page.tsx b/src/app/(auth)/forgot-password/page.tsx
index 8b289e2..ea286d3 100644
--- a/src/app/(auth)/forgot-password/page.tsx
+++ b/src/app/(auth)/forgot-password/page.tsx
@@ -1,59 +1,49 @@
"use client";
-import { useState } from "react";
+import { Suspense } from "react";
import Link from "next/link";
-import { useRouter } from "next/navigation";
-import { useForm } from "react-hook-form";
-import { zodResolver } from "@hookform/resolvers/zod";
import { Loader2 } from "lucide-react";
-import { Button } from "@/components/ui/button";
-import { Input } from "@/components/ui/input";
-import { Label } from "@/components/ui/label";
import { AuthFormWrapper } from "@/components/forms/auth-form-wrapper";
-import {
- forgotPasswordSchema,
- type ForgotPasswordFormData,
-} from "@/lib/validations/auth";
-import * as authApi from "@/lib/api/auth";
-import { ApiError } from "@/lib/api/client";
+import { KratosFlowForm } from "@/components/auth/kratos-flow-form";
+import { KratosMessages } from "@/components/auth/kratos-messages";
+import { useKratosFlow } from "@/lib/kratos/use-kratos-flow";
+import type { KratosFlow } from "@/lib/kratos";
-export default function ForgotPasswordPage() {
- const router = useRouter();
- const [isLoading, setIsLoading] = useState(false);
- const [error, setError] = useState(null);
+// ---------------------------------------------------------------------------
+// Forgot password — Ory Kratos `recovery` browser self-service flow
+// ---------------------------------------------------------------------------
+// The recovery flow is multi-step but single-page: the user first submits
+// their email, Kratos emails a code and returns the SAME flow re-rendered with
+// a "code" input. Submitting a valid code creates a privileged session and
+// Kratos redirects the browser to the `settings` flow to set a new password.
+//
+// We just keep rendering `flow.ui.nodes` — Kratos drives the step transitions.
+// ---------------------------------------------------------------------------
- const {
- register,
- handleSubmit,
- formState: { errors },
- } = useForm({
- resolver: zodResolver(forgotPasswordSchema),
- });
+function ForgotPasswordForm() {
+ const { flow, loading, error, setFlow } = useKratosFlow("recovery");
- async function onSubmit(data: ForgotPasswordFormData) {
- setIsLoading(true);
- setError(null);
- try {
- await authApi.forgotPassword({ email: data.email });
- router.push(
- `/reset-password?email=${encodeURIComponent(data.email)}`
- );
- } catch (err) {
- const message =
- err instanceof ApiError
- ? err.message
- : "Failed to send reset code. Please try again.";
- setError(message);
- } finally {
- setIsLoading(false);
+ function handleResult(result: { status: number; ok: boolean; data: unknown }) {
+ // A hard success (2xx with no body) means Kratos established a recovery
+ // session and is redirecting the browser to the settings flow. With
+ // `redirect: 'manual'` the redirect surfaces as ok=true / data=null —
+ // navigate to settings so the user can pick a new password.
+ if (result.ok && !result.data) {
+ window.location.href = "/settings";
+ return;
+ }
+ // Otherwise Kratos returned a flow body: either the same flow advanced to
+ // the "code" step, or the same step re-rendered with validation messages.
+ if (result.data && typeof result.data === "object" && "ui" in result.data) {
+ setFlow(result.data as KratosFlow);
}
}
return (
@@ -62,34 +52,25 @@ export default function ForgotPasswordPage() {