From 42e7bedea4ffb95810819d8fe54bf1474f946b6c Mon Sep 17 00:00:00 2001 From: Trey t Date: Mon, 18 May 2026 18:16:49 -0500 Subject: [PATCH] Replace hand-rolled auth with Ory Kratos browser flows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The honeyDue Go API no longer owns identity — Ory Kratos at NEXT_PUBLIC_KRATOS_URL does. Rewrite the web app's auth layer to use Kratos browser self-service flows and the ory_kratos_session cookie. - Kratos client (src/lib/kratos/): flow init/fetch/submit, whoami, logout, message helpers, and the useKratosFlow lifecycle hook. - Generic flow renderer (src/components/auth/): KratosFlowForm renders ui.nodes (inputs, oidc social buttons, hidden csrf), KratosMessages surfaces flow-level messages, AuthGate guards /app via whoami. - Auth pages (login/register/forgot-password/verify-email/reset-password) rewritten as Kratos login/registration/recovery/verification/settings flows. Password change in settings now uses the Kratos settings flow. - Proxy + serverFetch forward the ory_kratos_session cookie to the Go API instead of "Authorization: Token". Deleted /api/auth/{login,logout,me}. - Middleware does a cheap ory_kratos_session cookie pre-filter; AuthGate's whoami call is authoritative. - auth store rewritten around whoami + GET /auth/me; removed dead auth API functions, types/auth, validations/auth, code-input. - Added NEXT_PUBLIC_KRATOS_URL to config (.env.example) and CLAUDE.md. npm run build passes. Co-Authored-By: Claude Opus 4.7 (1M context) --- .env.example | 19 ++ CLAUDE.md | 21 +- src/app/(auth)/forgot-password/page.tsx | 113 +++---- src/app/(auth)/login/page.tsx | 124 +++---- src/app/(auth)/register/page.tsx | 189 +++-------- src/app/(auth)/reset-password/page.tsx | 227 ++++--------- src/app/(auth)/verify-email/page.tsx | 136 ++------ src/app/api/auth/login/route.ts | 76 ----- src/app/api/auth/logout/route.ts | 50 --- src/app/api/auth/me/route.ts | 60 ---- src/app/api/proxy/[...path]/route.ts | 18 +- src/app/app/layout.tsx | 23 +- src/components/auth/auth-gate.tsx | 57 ++++ src/components/auth/kratos-flow-form.tsx | 276 ++++++++++++++++ src/components/auth/kratos-messages.tsx | 70 ++++ src/components/forms/code-input.tsx | 107 ------ src/components/layout/top-bar.tsx | 13 +- .../settings/change-password-form.tsx | 183 ++++++----- src/lib/api/auth.ts | 309 +++--------------- src/lib/api/client.ts | 29 +- src/lib/demo/data-provider.ts | 6 +- src/lib/demo/demo-provider.ts | 4 +- src/lib/demo/real-provider.ts | 4 +- src/lib/hooks/use-auth.ts | 16 +- src/lib/kratos/client.ts | 240 ++++++++++++++ src/lib/kratos/index.ts | 29 ++ src/lib/kratos/types.ts | 114 +++++++ src/lib/kratos/use-kratos-flow.ts | 88 +++++ src/lib/types/auth.ts | 101 +----- src/lib/types/index.ts | 21 +- src/lib/validations/auth.ts | 84 ----- src/middleware.ts | 50 ++- src/stores/auth.ts | 121 ++++--- 33 files changed, 1474 insertions(+), 1504 deletions(-) create mode 100644 .env.example delete mode 100644 src/app/api/auth/login/route.ts delete mode 100644 src/app/api/auth/logout/route.ts delete mode 100644 src/app/api/auth/me/route.ts create mode 100644 src/components/auth/auth-gate.tsx create mode 100644 src/components/auth/kratos-flow-form.tsx create mode 100644 src/components/auth/kratos-messages.tsx delete mode 100644 src/components/forms/code-input.tsx create mode 100644 src/lib/kratos/client.ts create mode 100644 src/lib/kratos/index.ts create mode 100644 src/lib/kratos/types.ts create mode 100644 src/lib/kratos/use-kratos-flow.ts delete mode 100644 src/lib/validations/auth.ts 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() {

} > -
- {error && ( -
- {error} +
+ + + {loading && !flow && ( +
+
)} -
- - - {errors.email && ( - - )} -
- - - + {flow && } +
); } + +export default function ForgotPasswordPage() { + return ( + + + + ); +} diff --git a/src/app/(auth)/login/page.tsx b/src/app/(auth)/login/page.tsx index 88b1595..255176d 100644 --- a/src/app/(auth)/login/page.tsx +++ b/src/app/(auth)/login/page.tsx @@ -1,32 +1,38 @@ "use client"; +import { Suspense } from "react"; import Link from "next/link"; -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 { PasswordInput } from "@/components/forms/password-input"; -import { loginSchema, type LoginFormData } from "@/lib/validations/auth"; -import { useAuthStore } from "@/stores/auth"; +import { KratosFlowForm } from "@/components/auth/kratos-flow-form"; +import { KratosMessages } from "@/components/auth/kratos-messages"; +import { useKratosFlow } from "@/lib/kratos/use-kratos-flow"; +import { trackEvent, AnalyticsEvents } from "@/lib/analytics"; +import type { KratosFlow } from "@/lib/kratos"; -export default function LoginPage() { - const { login, isLoading, error, clearError } = useAuthStore(); +// --------------------------------------------------------------------------- +// Login — Ory Kratos `login` browser self-service flow +// --------------------------------------------------------------------------- - const { - register, - handleSubmit, - formState: { errors }, - } = useForm({ - resolver: zodResolver(loginSchema), - }); +function LoginForm() { + const { flow, loading, error, setFlow } = useKratosFlow("login"); - async function onSubmit(data: LoginFormData) { - clearError(); - await login({ username: data.username, password: data.password }); + function handleResult(result: { status: number; ok: boolean; data: unknown }) { + if (result.ok) { + // Kratos completed the login (set the `ory_kratos_session` cookie). + trackEvent(AnalyticsEvents.USER_SIGNED_IN, { + method: "kratos", + platform: "web", + }); + window.location.href = "/app"; + return; + } + // 400 = validation errors; Kratos returns the same flow re-rendered with + // messages. Re-render whatever Kratos returned. + if (result.data && typeof result.data === "object" && "ui" in result.data) { + setFlow(result.data as KratosFlow); + } } return ( @@ -42,59 +48,37 @@ export default function LoginPage() {

} > -
- {error && ( -
- {error} +
+ + + {loading && !flow && ( +
+
)} -
- - - {errors.username && ( - - )} -
- -
-
- - - Forgot password? - -
- - {errors.password && ( - - )} -
- - - + {flow && ( + <> + +
+ + Forgot password? + +
+ + )} +
); } + +export default function LoginPage() { + return ( + + + + ); +} diff --git a/src/app/(auth)/register/page.tsx b/src/app/(auth)/register/page.tsx index 9b7ac1e..4e074ad 100644 --- a/src/app/(auth)/register/page.tsx +++ b/src/app/(auth)/register/page.tsx @@ -1,46 +1,42 @@ "use client"; +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 { PasswordInput } from "@/components/forms/password-input"; -import { registerSchema, type RegisterFormData } from "@/lib/validations/auth"; -import { useAuthStore } from "@/stores/auth"; +import { KratosFlowForm } from "@/components/auth/kratos-flow-form"; +import { KratosMessages } from "@/components/auth/kratos-messages"; +import { useKratosFlow } from "@/lib/kratos/use-kratos-flow"; +import { trackEvent, AnalyticsEvents } from "@/lib/analytics"; +import type { KratosFlow } from "@/lib/kratos"; -export default function RegisterPage() { - const router = useRouter(); - const { register: registerUser, isLoading, error, clearError } = useAuthStore(); +// --------------------------------------------------------------------------- +// Registration — Ory Kratos `registration` browser self-service flow +// --------------------------------------------------------------------------- +// Kratos owns the schema (traits.email, traits.name.first, ...). The form is +// rendered entirely from `flow.ui.nodes`, so whatever the identity schema +// defines is what the user sees. +// --------------------------------------------------------------------------- - const { - register, - handleSubmit, - formState: { errors }, - } = useForm({ - resolver: zodResolver(registerSchema), - }); +function RegisterForm() { + const { flow, loading, error, setFlow } = useKratosFlow("registration"); - async function onSubmit(data: RegisterFormData) { - clearError(); - try { - await registerUser({ - first_name: data.first_name, - last_name: data.last_name, - username: data.username, - email: data.email, - password: data.password, + function handleResult(result: { status: number; ok: boolean; data: unknown }) { + if (result.ok) { + trackEvent(AnalyticsEvents.USER_REGISTERED, { + method: "kratos", + platform: "web", }); - router.push( - `/verify-email?email=${encodeURIComponent(data.email)}` - ); - } catch { - // Error is already set in the store + // Depending on Kratos config, registration may either log the user in + // immediately (session cookie set) or require email verification first. + // `/app` works in both cases — middleware / whoami will route the user + // to verification if a session is not yet active. + window.location.href = "/app"; + return; + } + if (result.data && typeof result.data === "object" && "ui" in result.data) { + setFlow(result.data as KratosFlow); } } @@ -57,116 +53,31 @@ export default function RegisterPage() {

} > -
- {error && ( -
- {error} +
+ + + {loading && !flow && ( +
+
)} -
-
- - - {errors.first_name && ( - - )} -
- -
- - - {errors.last_name && ( - - )} -
-
- -
- - - {errors.username && ( - - )} -
- -
- - - {errors.email && ( - - )} -
- -
- - - {errors.password && ( - - )} -
- -
- - - {errors.confirm_password && ( - - )} -
- - - + )} +
); } + +export default function RegisterPage() { + return ( + + + + ); +} diff --git a/src/app/(auth)/reset-password/page.tsx b/src/app/(auth)/reset-password/page.tsx index b948c9a..2ce3221 100644 --- a/src/app/(auth)/reset-password/page.tsx +++ b/src/app/(auth)/reset-password/page.tsx @@ -1,147 +1,66 @@ "use client"; -import { Suspense, useState } from "react"; +import { Suspense } from "react"; import Link from "next/link"; -import { useRouter, useSearchParams } 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 { Label } from "@/components/ui/label"; import { AuthFormWrapper } from "@/components/forms/auth-form-wrapper"; -import { PasswordInput } from "@/components/forms/password-input"; -import { CodeInput } from "@/components/forms/code-input"; -import { resetPasswordSchema, type ResetPasswordFormData } 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, KratosUiNode } from "@/lib/kratos"; -type Step = "code" | "password"; +// --------------------------------------------------------------------------- +// Reset password — Ory Kratos `settings` browser self-service flow +// --------------------------------------------------------------------------- +// Kratos does not have a standalone "reset password" flow. After completing +// the `recovery` flow, Kratos issues a privileged (recovery) session and +// redirects the browser here. The `settings` flow then lets the user set a +// new password. +// +// This page only renders the `password` group of the settings flow so it +// reads as a focused "set new password" screen. The full settings flow +// (profile, etc.) lives under /app/settings. +// --------------------------------------------------------------------------- function ResetPasswordForm() { - const router = useRouter(); - const searchParams = useSearchParams(); - const email = searchParams.get("email") ?? ""; + const { flow, loading, error, setFlow } = useKratosFlow("settings"); - const [step, setStep] = useState("code"); - const [code, setCode] = useState(""); - const [resetToken, setResetToken] = useState(""); - const [isLoading, setIsLoading] = useState(false); - const [error, setError] = useState(null); - - const { - register, - handleSubmit, - formState: { errors }, - } = useForm({ - resolver: zodResolver(resetPasswordSchema), - values: { - email, - code, - new_password: "", - confirm_password: "", - }, - }); - - // Step 1: Verify the 6-digit code - async function handleVerifyCode(submittedCode: string) { - if (submittedCode.length !== 6 || isLoading) return; - setIsLoading(true); - setError(null); - try { - const result = await authApi.verifyResetCode({ - email, - code: submittedCode, - }); - setResetToken(result.reset_token); - setStep("password"); - } catch (err) { - const message = - err instanceof ApiError - ? err.message - : "Invalid code. Please try again."; - setError(message); - } finally { - setIsLoading(false); + function handleResult(result: { status: number; ok: boolean; data: unknown }) { + if (result.data && typeof result.data === "object" && "ui" in result.data) { + const next = result.data as KratosFlow; + setFlow(next); + // Kratos sets the flow state to "success" once the password is updated. + if (next.state === "success") { + // Give the success message a beat, then send the user into the app. + setTimeout(() => { + window.location.href = "/app"; + }, 1200); + } + return; + } + if (result.ok) { + window.location.href = "/app"; } } - function handleCodeChange(newCode: string) { - setCode(newCode); - if (newCode.length === 6) { - handleVerifyCode(newCode); - } - } - - // Step 2: Reset password with the token - async function onSubmitPassword(data: ResetPasswordFormData) { - setIsLoading(true); - setError(null); - try { - await authApi.resetPassword({ - reset_token: resetToken, - new_password: data.new_password, - }); - router.push("/login"); - } catch (err) { - const message = - err instanceof ApiError - ? err.message - : "Failed to reset password. Please try again."; - setError(message); - } finally { - setIsLoading(false); - } - } - - if (step === "code") { - return ( - - - Back to login - -

- } - > -
- {error && ( -
- {error} -
- )} - - - - -
-
- ); - } + // Only render password-related nodes (csrf/method markers included). + const passwordOnlyFlow: KratosFlow | null = flow + ? { + ...flow, + ui: { + ...flow.ui, + nodes: flow.ui.nodes.filter( + (n: KratosUiNode) => n.group === "password" || n.group === "default", + ), + }, + } + : null; return ( @@ -150,51 +69,23 @@ function ResetPasswordForm() {

} > -
- {error && ( -
- {error} +
+ + + {loading && !flow && ( +
+
)} -
- - - {errors.new_password && ( -

- {errors.new_password.message} -

- )} -
- -
- - - {errors.confirm_password && ( -

- {errors.confirm_password.message} -

- )} -
- - - + )} +
); } diff --git a/src/app/(auth)/verify-email/page.tsx b/src/app/(auth)/verify-email/page.tsx index 57855a8..2f939d0 100644 --- a/src/app/(auth)/verify-email/page.tsx +++ b/src/app/(auth)/verify-email/page.tsx @@ -2,91 +2,44 @@ import { Suspense } from "react"; import Link from "next/link"; -import { useRouter, useSearchParams } from "next/navigation"; -import { useState, useEffect, useCallback } from "react"; import { Loader2 } from "lucide-react"; -import { Button } from "@/components/ui/button"; import { AuthFormWrapper } from "@/components/forms/auth-form-wrapper"; -import { CodeInput } from "@/components/forms/code-input"; -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"; -const RESEND_COOLDOWN_SECONDS = 60; +// --------------------------------------------------------------------------- +// Verify email — Ory Kratos `verification` browser self-service flow +// --------------------------------------------------------------------------- +// Like recovery, the verification flow is single-page and multi-step: the user +// submits their email, Kratos sends a code and re-renders the flow with a +// "code" input. A valid code marks the address verified. Kratos keeps the flow +// in `state: "passed_challenge"` and shows a success message — there is no +// session change, so we leave the user on the page. +// --------------------------------------------------------------------------- function VerifyEmailForm() { - const router = useRouter(); - const searchParams = useSearchParams(); - const email = searchParams.get("email") ?? ""; + const { flow, loading, error, setFlow } = useKratosFlow("verification"); - const [code, setCode] = useState(""); - const [isSubmitting, setIsSubmitting] = useState(false); - const [isResending, setIsResending] = useState(false); - const [error, setError] = useState(null); - const [cooldown, setCooldown] = useState(0); - - // Cooldown timer for resend button - useEffect(() => { - if (cooldown <= 0) return; - const timer = setInterval(() => { - setCooldown((c) => c - 1); - }, 1000); - return () => clearInterval(timer); - }, [cooldown]); - - const handleSubmit = useCallback( - async (submittedCode: string) => { - if (submittedCode.length !== 6 || isSubmitting) return; - setError(null); - setIsSubmitting(true); - try { - await authApi.verifyEmail({ code: submittedCode }); - router.push("/login"); - } catch (err) { - const message = - err instanceof ApiError - ? err.message - : "Verification failed. Please try again."; - setError(message); - } finally { - setIsSubmitting(false); - } - }, - [isSubmitting, router] - ); - - function handleCodeChange(newCode: string) { - setCode(newCode); - // Auto-submit when all 6 digits are entered - if (newCode.length === 6) { - handleSubmit(newCode); + function handleResult(result: { status: number; ok: boolean; data: unknown }) { + // Kratos returns the same flow re-rendered for every step (email -> code, + // and the final "passed_challenge" success). Just re-render it. + if (result.data && typeof result.data === "object" && "ui" in result.data) { + setFlow(result.data as KratosFlow); } } - async function handleResend() { - setIsResending(true); - setError(null); - try { - await authApi.resendVerification(); - setCooldown(RESEND_COOLDOWN_SECONDS); - } catch (err) { - const message = - err instanceof ApiError - ? err.message - : "Failed to resend code. Please try again."; - setError(message); - } finally { - setIsResending(false); - } - } + const verified = flow?.state === "passed_challenge"; return ( @@ -96,43 +49,18 @@ function VerifyEmailForm() {

} > -
- {error && ( -
- {error} +
+ + + {loading && !flow && ( +
+
)} - - - - -
- -
+ {flow && !verified && ( + + )}
); diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts deleted file mode 100644 index 652ec57..0000000 --- a/src/app/api/auth/login/route.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { cookies } from 'next/headers'; -import { NextRequest, NextResponse } from 'next/server'; - -// --------------------------------------------------------------------------- -// POST /api/auth/login -// --------------------------------------------------------------------------- -// Special route handler for login. On success, sets the auth token in an -// httpOnly cookie so it is never exposed to client-side JavaScript. -// --------------------------------------------------------------------------- - -const API_BASE_URL = - process.env.API_URL || - process.env.NEXT_PUBLIC_API_URL || - 'https://honeyDue.treytartt.com/api'; - -const COOKIE_NAME = 'honeydue-token'; -const COOKIE_MAX_AGE = 60 * 60 * 24 * 30; // 30 days - -export async function POST(request: NextRequest) { - try { - const body = await request.json(); - - const upstream = await fetch(`${API_BASE_URL}/auth/login/`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-Timezone': - request.headers.get('x-timezone') || - Intl.DateTimeFormat().resolvedOptions().timeZone, - }, - cache: 'no-store', - body: JSON.stringify(body), - }); - - const data = await upstream.json().catch(() => null); - - if (!upstream.ok) { - return NextResponse.json( - data || { error: 'Login failed' }, - { status: upstream.status }, - ); - } - - // Extract token from Go API response - // The Go API returns { token: "...", user: { ... } } - const token: string | undefined = data?.token; - - if (!token) { - return NextResponse.json( - { error: 'No token in response' }, - { status: 500 }, - ); - } - - // Set httpOnly cookie - const cookieStore = await cookies(); - cookieStore.set(COOKIE_NAME, token, { - httpOnly: true, - secure: process.env.NODE_ENV === 'production', - sameSite: 'strict', - path: '/', - maxAge: COOKIE_MAX_AGE, - }); - - // Return the full response (including user data) to the client, - // but strip the raw token since it is now in the cookie. - const { token: _stripped, ...safeData } = data; - return NextResponse.json(safeData, { status: 200 }); - } catch (error) { - console.error('[auth/login] Error:', error); - return NextResponse.json( - { error: 'Internal server error' }, - { status: 500 }, - ); - } -} diff --git a/src/app/api/auth/logout/route.ts b/src/app/api/auth/logout/route.ts deleted file mode 100644 index 1bc5b5a..0000000 --- a/src/app/api/auth/logout/route.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { cookies } from 'next/headers'; -import { NextRequest, NextResponse } from 'next/server'; - -// --------------------------------------------------------------------------- -// POST /api/auth/logout -// --------------------------------------------------------------------------- -// Clears the httpOnly auth cookie and optionally invalidates the token on -// the Go API side. -// --------------------------------------------------------------------------- - -const API_BASE_URL = - process.env.API_URL || - process.env.NEXT_PUBLIC_API_URL || - 'https://honeyDue.treytartt.com/api'; - -const COOKIE_NAME = 'honeydue-token'; - -export async function POST(request: NextRequest) { - try { - const cookieStore = await cookies(); - const token = cookieStore.get(COOKIE_NAME)?.value; - - // Best-effort: tell the Go API to invalidate the token - if (token) { - try { - await fetch(`${API_BASE_URL}/auth/logout/`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Token ${token}`, - }, - cache: 'no-store', - }); - } catch { - // Don't block logout if the upstream call fails - } - } - - // Delete the cookie - cookieStore.delete(COOKIE_NAME); - - return NextResponse.json({ message: 'Logged out successfully' }); - } catch (error) { - console.error('[auth/logout] Error:', error); - return NextResponse.json( - { error: 'Internal server error' }, - { status: 500 }, - ); - } -} diff --git a/src/app/api/auth/me/route.ts b/src/app/api/auth/me/route.ts deleted file mode 100644 index 84361ee..0000000 --- a/src/app/api/auth/me/route.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { cookies } from 'next/headers'; -import { NextRequest, NextResponse } from 'next/server'; - -// --------------------------------------------------------------------------- -// GET /api/auth/me -// --------------------------------------------------------------------------- -// Returns the current authenticated user. Reads the token from the httpOnly -// cookie and proxies to the Go API. -// --------------------------------------------------------------------------- - -const API_BASE_URL = - process.env.API_URL || - process.env.NEXT_PUBLIC_API_URL || - 'https://honeyDue.treytartt.com/api'; - -const COOKIE_NAME = 'honeydue-token'; - -export async function GET(_request: NextRequest) { - try { - const cookieStore = await cookies(); - const token = cookieStore.get(COOKIE_NAME)?.value; - - if (!token) { - return NextResponse.json( - { error: 'Not authenticated' }, - { status: 401 }, - ); - } - - const upstream = await fetch(`${API_BASE_URL}/auth/me/`, { - method: 'GET', - headers: { - 'Content-Type': 'application/json', - Authorization: `Token ${token}`, - }, - cache: 'no-store', - }); - - const data = await upstream.json().catch(() => null); - - if (!upstream.ok) { - // If the token is invalid/expired, clear the cookie - if (upstream.status === 401) { - cookieStore.delete(COOKIE_NAME); - } - return NextResponse.json( - data || { error: 'Failed to fetch user' }, - { status: upstream.status }, - ); - } - - return NextResponse.json(data); - } catch (error) { - console.error('[auth/me] Error:', error); - return NextResponse.json( - { error: 'Internal server error' }, - { status: 500 }, - ); - } -} diff --git a/src/app/api/proxy/[...path]/route.ts b/src/app/api/proxy/[...path]/route.ts index 9e75636..b7d1c2e 100644 --- a/src/app/api/proxy/[...path]/route.ts +++ b/src/app/api/proxy/[...path]/route.ts @@ -5,8 +5,9 @@ import { NextRequest, NextResponse } from 'next/server'; // Catch-all proxy route handler // --------------------------------------------------------------------------- // Every authenticated client-side API call goes through this proxy. -// It reads the `honeydue-token` httpOnly cookie and forwards the request to the -// Go API with an Authorization header. +// Identity is owned by Ory Kratos: the browser holds an `ory_kratos_session` +// cookie. This proxy reads that cookie and forwards it to the Go API as a +// Cookie header, so the Go API can validate the session against Kratos. // --------------------------------------------------------------------------- const API_BASE_URL = @@ -14,6 +15,8 @@ const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'https://honeyDue.treytartt.com/api'; +const KRATOS_SESSION_COOKIE = 'ory_kratos_session'; + /** * Build the target URL from the catch-all path segments. * e.g. /api/proxy/tasks/123/ -> https://honeyDue.treytartt.com/api/tasks/123/ @@ -30,7 +33,7 @@ function buildTargetUrl(request: NextRequest, pathSegments: string[]): string { /** * Build headers to forward to Go API. - * Strips hop-by-hop headers and adds Authorization from cookie. + * Strips hop-by-hop headers and forwards the Kratos session cookie. */ async function buildHeaders(request: NextRequest): Promise { const headers = new Headers(); @@ -51,11 +54,12 @@ async function buildHeaders(request: NextRequest): Promise { } } - // Attach auth token from httpOnly cookie + // Forward the Ory Kratos session cookie so the Go API can authenticate the + // request against Kratos. The Go API no longer accepts `Authorization: Token`. const cookieStore = await cookies(); - const token = cookieStore.get('honeydue-token')?.value; - if (token) { - headers.set('Authorization', `Token ${token}`); + const session = cookieStore.get(KRATOS_SESSION_COOKIE)?.value; + if (session) { + headers.set('Cookie', `${KRATOS_SESSION_COOKIE}=${session}`); } return headers; diff --git a/src/app/app/layout.tsx b/src/app/app/layout.tsx index 520a781..640c7ae 100644 --- a/src/app/app/layout.tsx +++ b/src/app/app/layout.tsx @@ -2,22 +2,25 @@ import { TopBar } from '@/components/layout/top-bar'; import { MobileNav } from '@/components/layout/mobile-nav'; +import { AuthGate } from '@/components/auth/auth-gate'; import { DataProviderProvider } from '@/lib/demo/data-provider-context'; import { realProvider } from '@/lib/demo/real-provider'; export default function AppLayout({ children }: { children: React.ReactNode }) { return ( - -
-
- + + +
+
+ -
- {children} -
+
+ {children} +
- -
- + +
+
+
); } diff --git a/src/components/auth/auth-gate.tsx b/src/components/auth/auth-gate.tsx new file mode 100644 index 0000000..a8aa6e1 --- /dev/null +++ b/src/components/auth/auth-gate.tsx @@ -0,0 +1,57 @@ +"use client"; + +// --------------------------------------------------------------------------- +// AuthGate — client-side route protection for authenticated app routes +// --------------------------------------------------------------------------- +// Identity is owned by Ory Kratos. Route protection is done by checking the +// live Kratos session via `whoami()`: +// - 200 / active session -> render the protected content +// - no session -> redirect to /login +// +// The Next.js middleware does a cheap cookie-presence pre-filter, but only a +// `whoami` call can confirm the session is actually valid (not expired). This +// gate is the authoritative check for the browser. +// --------------------------------------------------------------------------- + +import { useEffect, useState } from "react"; +import { useRouter } from "next/navigation"; +import { Loader2 } from "lucide-react"; + +import { useAuthStore } from "@/stores/auth"; + +export function AuthGate({ children }: { children: React.ReactNode }) { + const router = useRouter(); + const hydrate = useAuthStore((s) => s.hydrate); + const [checked, setChecked] = useState(false); + const [allowed, setAllowed] = useState(false); + + useEffect(() => { + let cancelled = false; + + hydrate().then(() => { + if (cancelled) return; + const { isAuthenticated } = useAuthStore.getState(); + if (isAuthenticated) { + setAllowed(true); + setChecked(true); + } else { + // No valid Kratos session — bounce to login. + router.replace("/login"); + } + }); + + return () => { + cancelled = true; + }; + }, [hydrate, router]); + + if (!checked || !allowed) { + return ( +
+ +
+ ); + } + + return <>{children}; +} diff --git a/src/components/auth/kratos-flow-form.tsx b/src/components/auth/kratos-flow-form.tsx new file mode 100644 index 0000000..e18732c --- /dev/null +++ b/src/components/auth/kratos-flow-form.tsx @@ -0,0 +1,276 @@ +"use client"; + +// --------------------------------------------------------------------------- +// KratosFlowForm — generic renderer for an Ory Kratos self-service flow +// --------------------------------------------------------------------------- +// Renders `flow.ui.nodes` into a form, submits to `flow.ui.action`, and shows +// the validation messages Kratos attaches to nodes / the flow. +// +// Node groups handled: +// - default / password / code / profile -> rendered as inputs & the submit +// - oidc -> rendered as social sign-in buttons +// - hidden inputs (csrf_token, etc.) -> rendered as +// --------------------------------------------------------------------------- + +import { useMemo, useState } from "react"; +import { Loader2 } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { PasswordInput } from "@/components/forms/password-input"; +import { submitFlow, type KratosFlow, type KratosUiNode } from "@/lib/kratos"; + +// --------------------------------------------------------------------------- +// Node classification helpers +// --------------------------------------------------------------------------- + +function isHidden(node: KratosUiNode): boolean { + return node.attributes.node_type === "input" && node.attributes.type === "hidden"; +} + +function isSubmit(node: KratosUiNode): boolean { + return ( + node.attributes.node_type === "input" && + (node.attributes.type === "submit" || node.attributes.type === "button") + ); +} + +function isOidc(node: KratosUiNode): boolean { + return node.group === "oidc"; +} + +/** Human label for a node, falling back to the field name. */ +function nodeLabel(node: KratosUiNode): string { + return ( + node.meta?.label?.text || + node.attributes.label?.text || + node.attributes.name || + "" + ); +} + +/** Pretty provider name for an oidc button ("apple" -> "Apple"). */ +function providerName(node: KratosUiNode): string { + const raw = + String(node.attributes.value ?? "") || + nodeLabel(node).replace(/^sign in with /i, ""); + return raw.charAt(0).toUpperCase() + raw.slice(1); +} + +// --------------------------------------------------------------------------- +// Component +// --------------------------------------------------------------------------- + +interface KratosFlowFormProps { + flow: KratosFlow; + /** + * Called after a submit. Receives the HTTP status, the parsed body, and + * whether the request was a 2xx. The page decides what to do next + * (redirect, advance a step, re-render the returned flow, ...). + */ + onResult: (result: { status: number; ok: boolean; data: unknown }) => void; + /** Optional override for the primary submit button label. */ + submitLabel?: string; + /** Field names to omit from rendering (e.g. already-known email). */ + hideFields?: string[]; +} + +export function KratosFlowForm({ + flow, + onResult, + submitLabel, + hideFields = [], +}: KratosFlowFormProps) { + const [submitting, setSubmitting] = useState(false); + const [activeSubmit, setActiveSubmit] = useState(null); + + const { hiddenNodes, oidcNodes, fieldNodes, submitNodes } = useMemo(() => { + const hidden: KratosUiNode[] = []; + const oidc: KratosUiNode[] = []; + const fields: KratosUiNode[] = []; + const submits: KratosUiNode[] = []; + for (const node of flow.ui.nodes) { + if (isHidden(node)) hidden.push(node); + else if (isOidc(node)) oidc.push(node); + else if (isSubmit(node)) submits.push(node); + else if (node.attributes.node_type === "input") fields.push(node); + } + return { + hiddenNodes: hidden, + oidcNodes: oidc, + fieldNodes: fields.filter( + (n) => !hideFields.includes(n.attributes.name ?? ""), + ), + submitNodes: submits, + }; + }, [flow, hideFields]); + + /** The primary (non-oidc) submit button, if any. */ + const primarySubmit = submitNodes.find((n) => n.group !== "oidc"); + + async function runSubmit( + formEl: HTMLFormElement, + submitName?: string, + submitValue?: string, + ) { + if (submitting) return; + setSubmitting(true); + setActiveSubmit(submitValue ?? submitName ?? null); + + // Collect every input value from the form. + const body: Record = {}; + const data = new FormData(formEl); + for (const [key, value] of data.entries()) { + if (typeof value === "string") body[key] = value; + } + // The clicked submit button's name/value must be included so Kratos knows + // which method was used (e.g. method=password, or provider=google). + if (submitName && submitValue !== undefined) { + body[submitName] = submitValue; + } + + try { + const result = await submitFlow(flow.ui.action, flow.ui.method, body); + onResult(result); + } finally { + setSubmitting(false); + setActiveSubmit(null); + } + } + + function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + const form = e.currentTarget; + if (primarySubmit?.attributes.name) { + runSubmit( + form, + primarySubmit.attributes.name, + String(primarySubmit.attributes.value ?? ""), + ); + } else { + runSubmit(form); + } + } + + return ( +
+ {/* Hidden inputs (csrf_token, flow id, method markers, ...) */} + {hiddenNodes.map((node, i) => ( + + ))} + + {/* Visible input fields */} + {fieldNodes.map((node) => ( + + ))} + + {/* Primary submit */} + {primarySubmit && ( + + )} + + {/* Social sign-in (oidc) */} + {oidcNodes.length > 0 && ( +
+ {(fieldNodes.length > 0 || primarySubmit) && ( +
+ + or continue with + + +
+ )} + {oidcNodes.map((node) => ( + + ))} +
+ )} + + ); +} + +// --------------------------------------------------------------------------- +// FieldNode — a single visible input + its label + node-level messages +// --------------------------------------------------------------------------- + +function FieldNode({ node }: { node: KratosUiNode }) { + const name = node.attributes.name ?? ""; + const type = node.attributes.type ?? "text"; + const label = nodeLabel(node); + const isPassword = type === "password"; + const errors = node.messages?.filter((m) => m.type === "error") ?? []; + const hasError = errors.length > 0; + const fieldId = `kratos-field-${name}`; + + return ( +
+ {label && } + {isPassword ? ( + + ) : ( + + )} + {errors.map((m, i) => ( + + ))} +
+ ); +} diff --git a/src/components/auth/kratos-messages.tsx b/src/components/auth/kratos-messages.tsx new file mode 100644 index 0000000..9bc332e --- /dev/null +++ b/src/components/auth/kratos-messages.tsx @@ -0,0 +1,70 @@ +"use client"; + +// --------------------------------------------------------------------------- +// KratosMessages — renders flow-level messages (ui.messages) as banners +// --------------------------------------------------------------------------- +// Node-level (per-field) messages are rendered inline by KratosFlowForm. +// This component surfaces the flow-wide messages: "An email containing a +// recovery code has been sent", "The recovery code is invalid or has already +// been used", etc. +// --------------------------------------------------------------------------- + +import { CheckCircle2, CircleAlert, Info } from "lucide-react"; +import type { KratosFlow, KratosMessage } from "@/lib/kratos"; + +function variantClasses(type: string): string { + switch (type) { + case "error": + return "bg-destructive/10 text-destructive"; + case "success": + return "bg-green-500/10 text-green-700 dark:text-green-400"; + default: + return "bg-primary/10 text-primary"; + } +} + +function Icon({ type }: { type: string }) { + if (type === "error") return ; + if (type === "success") return ; + return ; +} + +interface KratosMessagesProps { + /** Either the whole flow (ui.messages used) or an explicit message list. */ + flow?: KratosFlow | null; + messages?: KratosMessage[]; + /** Extra ad-hoc error string (e.g. a network failure outside Kratos). */ + error?: string | null; +} + +export function KratosMessages({ flow, messages, error }: KratosMessagesProps) { + const list: KratosMessage[] = messages ?? flow?.ui.messages ?? []; + + if (list.length === 0 && !error) return null; + + return ( +
+ {error && ( +
+ + {error} +
+ )} + {list.map((m, i) => ( +
+ + {m.text} +
+ ))} +
+ ); +} diff --git a/src/components/forms/code-input.tsx b/src/components/forms/code-input.tsx deleted file mode 100644 index 6a43940..0000000 --- a/src/components/forms/code-input.tsx +++ /dev/null @@ -1,107 +0,0 @@ -"use client"; - -import * as React from "react"; -import { Input } from "@/components/ui/input"; -import { cn } from "@/lib/utils"; - -interface CodeInputProps { - value: string; - onChange: (code: string) => void; - disabled?: boolean; - className?: string; -} - -export function CodeInput({ - value, - onChange, - disabled = false, - className, -}: CodeInputProps) { - const inputRefs = React.useRef<(HTMLInputElement | null)[]>([]); - const digits = value.padEnd(6, "").slice(0, 6).split(""); - - function updateCode(newDigits: string[]) { - onChange(newDigits.join("")); - } - - function handleChange(index: number, char: string) { - // Accept only single digits - if (char && !/^\d$/.test(char)) return; - - const next = [...digits]; - next[index] = char; - updateCode(next); - - // Auto-advance to next input - if (char && index < 5) { - inputRefs.current[index + 1]?.focus(); - } - } - - function handleKeyDown( - index: number, - e: React.KeyboardEvent - ) { - if (e.key === "Backspace") { - e.preventDefault(); - if (digits[index]) { - // Clear current digit - const next = [...digits]; - next[index] = ""; - updateCode(next); - } else if (index > 0) { - // Move to previous and clear it - const next = [...digits]; - next[index - 1] = ""; - updateCode(next); - inputRefs.current[index - 1]?.focus(); - } - } else if (e.key === "ArrowLeft" && index > 0) { - inputRefs.current[index - 1]?.focus(); - } else if (e.key === "ArrowRight" && index < 5) { - inputRefs.current[index + 1]?.focus(); - } - } - - function handlePaste(e: React.ClipboardEvent) { - e.preventDefault(); - const pasted = e.clipboardData - .getData("text") - .replace(/\D/g, "") - .slice(0, 6); - if (!pasted) return; - - const next = [...digits]; - for (let i = 0; i < pasted.length && i < 6; i++) { - next[i] = pasted[i]; - } - updateCode(next); - - // Focus the input after the last pasted digit - const focusIndex = Math.min(pasted.length, 5); - inputRefs.current[focusIndex]?.focus(); - } - - return ( -
- {digits.map((digit, i) => ( - { - inputRefs.current[i] = el; - }} - type="text" - inputMode="numeric" - maxLength={1} - value={digit} - disabled={disabled} - className="h-12 w-12 text-center text-lg font-semibold" - onChange={(e) => handleChange(i, e.target.value.slice(-1))} - onKeyDown={(e) => handleKeyDown(i, e)} - onPaste={handlePaste} - autoComplete="one-time-code" - /> - ))} -
- ); -} diff --git a/src/components/layout/top-bar.tsx b/src/components/layout/top-bar.tsx index 8cb48d1..e838430 100644 --- a/src/components/layout/top-bar.tsx +++ b/src/components/layout/top-bar.tsx @@ -23,6 +23,7 @@ export function TopBar() { const pathname = usePathname(); const { basePath } = useDataProvider(); const user = useAuthStore((s) => s.user); + const logout = useAuthStore((s) => s.logout); const navItems = getNavItems(basePath).filter((item) => item.label !== 'Settings'); const initials = user @@ -30,16 +31,14 @@ export function TopBar() { : 'U'; const handleLogout = async () => { - try { - await fetch('/api/auth/logout', { method: 'POST' }); - } catch { - // Continue with redirect even if the API call fails - } if (basePath.startsWith('/demo')) { + // Demo mode has no real session — just leave demo. router.push('/demo'); - } else { - router.push('/login'); + return; } + // Real mode: drive the Kratos browser logout flow. This clears the + // `ory_kratos_session` cookie and navigates the browser itself. + await logout(); }; return ( diff --git a/src/components/settings/change-password-form.tsx b/src/components/settings/change-password-form.tsx index b955c4d..8ddaa7e 100644 --- a/src/components/settings/change-password-form.tsx +++ b/src/components/settings/change-password-form.tsx @@ -1,117 +1,116 @@ "use client"; -import { useState } from "react"; -import { useForm } from "react-hook-form"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { z } from "zod"; -import { toast } from "sonner"; -import { Loader2, Check } from "lucide-react"; +// --------------------------------------------------------------------------- +// ChangePasswordForm — password changes are owned by Ory Kratos +// --------------------------------------------------------------------------- +// The honeyDue Go API no longer handles passwords. Changing a password is a +// Kratos `settings` browser self-service flow. This card renders the password +// group of that flow inline so the user never leaves the settings page. +// --------------------------------------------------------------------------- + +import { useEffect, useState } from "react"; +import { Loader2 } from "lucide-react"; -import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"; -import { FormField } from "@/components/shared/form-field"; -import { PasswordInput } from "@/components/forms/password-input"; -import * as authApi from "@/lib/api/auth"; - -const changePasswordSchema = z - .object({ - current_password: z.string().min(8, "Password must be at least 8 characters"), - new_password: z.string().min(8, "Password must be at least 8 characters"), - confirm_password: z.string(), - }) - .refine((data) => data.new_password === data.confirm_password, { - message: "Passwords don't match", - path: ["confirm_password"], - }); - -type ChangePasswordFormData = z.infer; +import { KratosFlowForm } from "@/components/auth/kratos-flow-form"; +import { KratosMessages } from "@/components/auth/kratos-messages"; +import { + browserFlowUrl, + getFlow, + type KratosFlow, + type KratosUiNode, +} from "@/lib/kratos"; export function ChangePasswordForm() { - const [success, setSuccess] = useState(false); - const [apiError, setApiError] = useState(null); + const [flow, setFlow] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); - const { - register, - handleSubmit, - reset, - formState: { errors, isSubmitting }, - } = useForm({ - resolver: zodResolver(changePasswordSchema), - }); + // Initialize a Kratos settings flow on mount. We fetch it directly (the user + // is already logged in, so the settings flow can be created via the browser + // endpoint, which responds with the flow JSON for a live session). + useEffect(() => { + let cancelled = false; - async function onSubmit(data: ChangePasswordFormData) { - setSuccess(false); - setApiError(null); - try { - await authApi.changePassword({ - current_password: data.current_password, - new_password: data.new_password, + // State is only mutated from the async callbacks — the initial + // `loading: true` covers the in-flight window. + fetch(browserFlowUrl("settings"), { + credentials: "include", + headers: { Accept: "application/json" }, + redirect: "follow", + }) + .then(async (res) => { + if (cancelled) return; + if (res.ok) { + const data = (await res.json()) as KratosFlow; + setFlow(data); + setError(null); + } else { + setError("Could not load the password form."); + } + }) + .catch(() => { + if (!cancelled) setError("Could not load the password form."); + }) + .finally(() => { + if (!cancelled) setLoading(false); }); - reset(); - setSuccess(true); - toast.success("Password changed"); - } catch (err) { - const message = - err instanceof Error ? err.message : "Failed to change password."; - setApiError(message); - toast.error("Failed to change password"); + + return () => { + cancelled = true; + }; + }, []); + + function handleResult(result: { status: number; ok: boolean; data: unknown }) { + if (result.data && typeof result.data === "object" && "ui" in result.data) { + setFlow(result.data as KratosFlow); + } else if (result.ok && flow) { + // Re-fetch the flow so the form (and any success message) is fresh. + getFlow("settings", flow.id) + .then(setFlow) + .catch(() => {}); } } + // Render only the password-related nodes of the settings flow. + const passwordFlow: KratosFlow | null = flow + ? { + ...flow, + ui: { + ...flow.ui, + nodes: flow.ui.nodes.filter( + (n: KratosUiNode) => n.group === "password" || n.group === "default", + ), + }, + } + : null; + return ( Change Password - Update your password to keep your account secure. + + Update your password to keep your account secure. + -
- {apiError && ( -
- {apiError} -
- )} - {success && ( -
- - Password changed successfully. +
+ + + {loading && !flow && ( +
+
)} - - - - - - - - - - - - -
- -
- + )} +
); diff --git a/src/lib/api/auth.ts b/src/lib/api/auth.ts index ebc5f46..91db6d4 100644 --- a/src/lib/api/auth.ts +++ b/src/lib/api/auth.ts @@ -1,41 +1,27 @@ // --------------------------------------------------------------------------- -// Auth API client (client-side) +// Auth / profile API client (client-side) // --------------------------------------------------------------------------- -// Login & logout go through dedicated Next.js route handlers that manage the -// httpOnly cookie. All other auth routes use the catch-all proxy. +// Identity (login, registration, recovery, verification, password changes, +// account deletion, social sign-in) is owned by Ory Kratos — see +// `src/lib/kratos/`. The honeyDue Go API no longer does auth. +// +// What remains here is the honeyDue *profile* surface, which still lives on +// the Go API and is authenticated by the `ory_kratos_session` cookie: +// - GET /auth/me -> the current user's honeyDue profile +// - PUT /auth/profile -> update honeyDue-side profile fields // --------------------------------------------------------------------------- -import { ApiError } from './client'; +import { apiFetch } from './client'; // --------------------------------------------------------------------------- -// Request / response shapes (inline; will unify with @/lib/types later) -// TODO: import from @/lib/types once the shared types package is finalised +// Response shapes // --------------------------------------------------------------------------- -export interface LoginRequest { - username?: string; - email?: string; - password: string; -} - -/** Login response after the route handler strips the raw token. */ -export interface LoginResponse { - user: UserResponse; -} - -export interface RegisterRequest { - username: string; - email: string; - password: string; - first_name?: string; - last_name?: string; -} - -export interface RegisterResponse { - token: string; - user: UserResponse; -} - +/** + * The current user as returned by the honeyDue Go API `GET /auth/me`. + * The canonical identity (email, verification status) is owned by Kratos; + * this is the honeyDue-side projection used to render the app UI. + */ export interface UserResponse { id: number; username: string; @@ -53,245 +39,50 @@ export interface UpdateProfileRequest { last_name?: string; } -export interface ForgotPasswordRequest { - email: string; +// --------------------------------------------------------------------------- +// API functions +// --------------------------------------------------------------------------- + +/** + * Get the currently authenticated user's honeyDue profile. + * Requires a valid Kratos session (`ory_kratos_session` cookie), which the + * proxy forwards to the Go API. + */ +export function getCurrentUser(): Promise { + return apiFetch('/auth/me/'); } -export interface VerifyResetCodeRequest { - email: string; - code: string; -} - -export interface VerifyResetCodeResponse { - message: string; - reset_token: string; -} - -export interface ResetPasswordRequest { - reset_token: string; - new_password: string; -} - -export interface VerifyEmailRequest { - code: string; -} - -export interface VerifyEmailResponse { - message: string; - verified: boolean; +/** + * Update the authenticated user's honeyDue-side profile. + * Note: changing the email/password of the *identity* itself is done through + * the Kratos `settings` flow, not here. + */ +export function updateProfile( + data: UpdateProfileRequest, +): Promise { + return apiFetch('/auth/profile/', { + method: 'PUT', + body: JSON.stringify(data), + }); } export interface MessageResponse { message: string; } -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -async function handleResponse(res: Response): Promise { - const body = await res.json().catch(() => ({ error: 'Unknown error' })); - if (!res.ok) { - throw new ApiError( - res.status, - body.message || body.error || 'Request failed', - body.details, - ); - } - return body as T; -} - -function timezone(): string { - return Intl.DateTimeFormat().resolvedOptions().timeZone; -} - -// --------------------------------------------------------------------------- -// Public API functions -// --------------------------------------------------------------------------- - /** - * Log in with username/email + password. - * Uses the dedicated `/api/auth/login` route handler which sets the httpOnly - * cookie and strips the token from the response. + * Delete the authenticated user's honeyDue account data. + * + * TODO(kratos): This deletes the honeyDue-side data via the Go API. The Kratos + * *identity* itself must also be removed for a full account deletion. Kratos + * does not expose a self-service "delete identity" browser flow — that has to + * be done either by the Go API server-side (admin API) as part of this call, + * or via a separate admin/back-office process. Verify the Go API's + * `DELETE /auth/account` also tears down the Kratos identity; if it does not, + * a follow-up is needed. */ -export async function login(credentials: LoginRequest): Promise { - const res = await fetch('/api/auth/login', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-Timezone': timezone(), - }, - body: JSON.stringify(credentials), - }); - return handleResponse(res); -} - -/** - * Register a new account. - * Goes through the proxy; the token is returned in the response body - * (caller should follow up with `login` to set the cookie). - */ -export async function register( - data: RegisterRequest, -): Promise { - const res = await fetch('/api/proxy/auth/register', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-Timezone': timezone(), - }, - body: JSON.stringify(data), - }); - return handleResponse(res); -} - -/** - * Log out. Clears the httpOnly cookie and invalidates the token server-side. - */ -export async function logout(): Promise { - const res = await fetch('/api/auth/logout', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - }); - return handleResponse(res); -} - -/** - * Get the currently authenticated user. - */ -export async function getCurrentUser(): Promise { - const res = await fetch('/api/auth/me', { - headers: { 'Content-Type': 'application/json' }, - }); - return handleResponse(res); -} - -/** - * Update the authenticated user's profile. - */ -export async function updateProfile( - data: UpdateProfileRequest, -): Promise { - const res = await fetch('/api/proxy/auth/profile', { - method: 'PUT', - headers: { - 'Content-Type': 'application/json', - 'X-Timezone': timezone(), - }, - body: JSON.stringify(data), - }); - return handleResponse(res); -} - -/** - * Verify the user's email with a 6-digit code. - */ -export async function verifyEmail( - data: VerifyEmailRequest, -): Promise { - const res = await fetch('/api/proxy/auth/verify-email', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-Timezone': timezone(), - }, - body: JSON.stringify(data), - }); - return handleResponse(res); -} - -/** - * Resend the email verification code. - */ -export async function resendVerification(): Promise { - const res = await fetch('/api/proxy/auth/resend-verification', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-Timezone': timezone(), - }, - }); - return handleResponse(res); -} - -/** - * Request a password reset email. - */ -export async function forgotPassword( - data: ForgotPasswordRequest, -): Promise { - const res = await fetch('/api/proxy/auth/forgot-password', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-Timezone': timezone(), - }, - body: JSON.stringify(data), - }); - return handleResponse(res); -} - -/** - * Verify the 6-digit reset code; returns a reset token. - */ -export async function verifyResetCode( - data: VerifyResetCodeRequest, -): Promise { - const res = await fetch('/api/proxy/auth/verify-reset-code', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-Timezone': timezone(), - }, - body: JSON.stringify(data), - }); - return handleResponse(res); -} - -/** - * Reset password using the token from `verifyResetCode`. - */ -export async function resetPassword( - data: ResetPasswordRequest, -): Promise { - const res = await fetch('/api/proxy/auth/reset-password', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-Timezone': timezone(), - }, - body: JSON.stringify(data), - }); - return handleResponse(res); -} - -/** - * Change the authenticated user's password. - */ -export async function changePassword( - data: { current_password: string; new_password: string }, -): Promise { - const res = await fetch('/api/proxy/auth/change-password', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-Timezone': timezone(), - }, - body: JSON.stringify(data), - }); - return handleResponse(res); -} - -/** - * Delete the authenticated user's account permanently. - */ -export async function deleteAccount(): Promise { - const res = await fetch('/api/proxy/auth/delete-account', { +export function deleteAccount(): Promise { + return apiFetch('/auth/account/', { method: 'DELETE', - headers: { - 'Content-Type': 'application/json', - 'X-Timezone': timezone(), - }, }); - return handleResponse(res); } diff --git a/src/lib/api/client.ts b/src/lib/api/client.ts index 91fb00f..4997428 100644 --- a/src/lib/api/client.ts +++ b/src/lib/api/client.ts @@ -1,9 +1,12 @@ // --------------------------------------------------------------------------- // Base API client for honeyDue web app // --------------------------------------------------------------------------- -// All client-side requests go through Next.js API route handlers (proxy). -// The proxy reads the httpOnly `honeydue-token` cookie and forwards it to the -// Go API as an Authorization header. This avoids exposing the token to JS. +// Identity is owned by Ory Kratos. Authenticated requests to the honeyDue Go +// API carry the Kratos session via the `ory_kratos_session` cookie. +// +// All client-side requests go through Next.js API route handlers (the proxy). +// The proxy reads the `ory_kratos_session` httpOnly cookie and forwards it as +// a Cookie header to the Go API (which validates the session against Kratos). // --------------------------------------------------------------------------- const API_BASE_URL = @@ -15,6 +18,9 @@ const API_BASE_URL = */ const SERVER_API_URL = process.env.API_URL || API_BASE_URL; +/** Name of the Kratos browser session cookie. */ +export const KRATOS_SESSION_COOKIE = 'ory_kratos_session'; + // --------------------------------------------------------------------------- // Error class // --------------------------------------------------------------------------- @@ -36,7 +42,7 @@ export class ApiError extends Error { /** * Client-side authenticated fetch. Calls the Next.js catch-all proxy which - * attaches the auth token from the httpOnly cookie before forwarding to Go. + * forwards the `ory_kratos_session` cookie to the Go API. * * @param path API path *without* the `/api` prefix, e.g. `/tasks/` * @param options Standard RequestInit overrides @@ -64,7 +70,8 @@ export async function apiFetch( delete headers['Content-Type']; } - const res = await fetch(url, { ...options, headers }); + // Include cookies so the proxy (same-origin) receives the Kratos session. + const res = await fetch(url, { ...options, headers, credentials: 'include' }); if (!res.ok) { const body = await res.json().catch(() => ({ error: 'Unknown error' })); @@ -86,8 +93,9 @@ export async function apiFetch( // --------------------------------------------------------------------------- /** - * Server-side fetch that reads the auth token from the `honeydue-token` cookie - * and calls the Go API directly (no proxy hop). + * Server-side fetch that reads the Kratos session cookie and calls the Go API + * directly (no proxy hop). The `ory_kratos_session` cookie is forwarded as a + * Cookie header so the Go API can validate the session against Kratos. * * Only use this inside: * - `app/api/.../route.ts` handlers @@ -102,7 +110,7 @@ export async function serverFetch( // (the function itself should only be *called* on the server). const { cookies } = await import('next/headers'); const cookieStore = await cookies(); - const token = cookieStore.get('honeydue-token')?.value; + const session = cookieStore.get(KRATOS_SESSION_COOKIE)?.value; const normalized = path.endsWith('/') ? path : `${path}/`; const url = `${SERVER_API_URL}${normalized}`; @@ -113,8 +121,9 @@ export async function serverFetch( ...(options.headers as Record), }; - if (token) { - headers['Authorization'] = `Token ${token}`; + // Forward the Kratos session cookie to the Go API. + if (session) { + headers['Cookie'] = `${KRATOS_SESSION_COOKIE}=${session}`; } if (options.body instanceof FormData) { diff --git a/src/lib/demo/data-provider.ts b/src/lib/demo/data-provider.ts index ce9426d..119b3c5 100644 --- a/src/lib/demo/data-provider.ts +++ b/src/lib/demo/data-provider.ts @@ -153,7 +153,11 @@ export interface DataProvider { auth: { getCurrentUser(): Promise; - logout(): Promise; + /** + * Real mode: drives the Ory Kratos browser logout flow (clears the + * session cookie and navigates the browser). Demo mode: a no-op. + */ + logout(): Promise; }; } diff --git a/src/lib/demo/demo-provider.ts b/src/lib/demo/demo-provider.ts index dd84f3e..f7f7e49 100644 --- a/src/lib/demo/demo-provider.ts +++ b/src/lib/demo/demo-provider.ts @@ -350,6 +350,8 @@ export const demoProvider: DataProvider = { // ----------------------------------------------------------------------- auth: { getCurrentUser: async () => demoUser, - logout: async () => ({ message: 'Logged out' }), + // Demo mode has no real session — logout is a no-op (useLogout handles + // the /demo redirect). + logout: async () => {}, }, }; diff --git a/src/lib/demo/real-provider.ts b/src/lib/demo/real-provider.ts index 9bf7a0e..1b5bb9b 100644 --- a/src/lib/demo/real-provider.ts +++ b/src/lib/demo/real-provider.ts @@ -11,6 +11,7 @@ import * as lookupsApi from '@/lib/api/lookups'; import * as notificationsApi from '@/lib/api/notifications'; import * as subscriptionApi from '@/lib/api/subscription'; import * as authApi from '@/lib/api/auth'; +import { logout as kratosLogout } from '@/lib/kratos'; export const realProvider: DataProvider = { basePath: '/app', @@ -94,6 +95,7 @@ export const realProvider: DataProvider = { auth: { getCurrentUser: () => authApi.getCurrentUser(), - logout: () => authApi.logout(), + // Hands the browser off to the Kratos logout flow. + logout: () => kratosLogout(), }, }; diff --git a/src/lib/hooks/use-auth.ts b/src/lib/hooks/use-auth.ts index 4123c33..477a537 100644 --- a/src/lib/hooks/use-auth.ts +++ b/src/lib/hooks/use-auth.ts @@ -1,8 +1,16 @@ "use client"; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { useDataProvider, useQueryKeyPrefix } from '@/lib/demo/data-provider-context'; import { useRouter } from 'next/navigation'; +import { useDataProvider, useQueryKeyPrefix } from '@/lib/demo/data-provider-context'; + +// --------------------------------------------------------------------------- +// Auth hooks +// --------------------------------------------------------------------------- +// Identity is owned by Ory Kratos. `getCurrentUser` reads the honeyDue-side +// profile from the Go API (authenticated via the Kratos session cookie); +// `logout` drives the Kratos browser logout flow. +// --------------------------------------------------------------------------- export function useCurrentUser() { const { auth } = useDataProvider(); @@ -24,11 +32,11 @@ export function useLogout() { mutationFn: () => auth.logout(), onSuccess: () => { queryClient.clear(); - // In demo mode, redirect to /demo; in real mode, redirect to /login + // In demo mode there is no Kratos session — just route back to /demo. + // In real mode, auth.logout() already hands the browser off to the + // Kratos logout flow, so this router.push is only a fallback. if (basePath.startsWith('/demo')) { router.push('/demo'); - } else { - router.push('/login'); } }, }); diff --git a/src/lib/kratos/client.ts b/src/lib/kratos/client.ts new file mode 100644 index 0000000..51e4b9d --- /dev/null +++ b/src/lib/kratos/client.ts @@ -0,0 +1,240 @@ +// --------------------------------------------------------------------------- +// Ory Kratos browser client +// --------------------------------------------------------------------------- +// Identity is owned by Ory Kratos. The browser talks to the Kratos public API +// directly using its self-service browser flows. Every request must include +// credentials so the flow + `ory_kratos_session` cookies are sent. +// +// Flow lifecycle (browser): +// 1. Navigate the browser to {kratos}/self-service/{type}/browser +// -> Kratos sets a flow cookie and 303-redirects to the configured +// `ui_url` with `?flow=`. +// 2. The UI page reads `?flow=` and fetches the flow definition via +// getFlow() to obtain `ui.nodes` / `ui.action` / `ui.method`. +// 3. The UI renders the form and submits it to `ui.action`. +// --------------------------------------------------------------------------- + +import type { + FlowType, + KratosFlow, + KratosLogoutFlow, + KratosMessage, + KratosSession, +} from './types'; + +/** Base URL of the Kratos public API, e.g. https://auth.myhoneydue.com */ +export const KRATOS_URL = + process.env.NEXT_PUBLIC_KRATOS_URL || 'https://auth.myhoneydue.com'; + +/** Always send credentials so flow + session cookies flow with the request. */ +const CREDENTIALS: RequestCredentials = 'include'; + +/** Maps a flow type to its self-service flow path segment. */ +const FLOW_PATH: Record = { + login: 'login', + registration: 'registration', + recovery: 'recovery', + verification: 'verification', + settings: 'settings', +}; + +// --------------------------------------------------------------------------- +// Error +// --------------------------------------------------------------------------- + +export class KratosError extends Error { + constructor( + public status: number, + message: string, + /** Parsed flow body if Kratos returned a 4xx with a fresh flow. */ + public flow?: KratosFlow, + ) { + super(message); + this.name = 'KratosError'; + } +} + +// --------------------------------------------------------------------------- +// Flow initialization +// --------------------------------------------------------------------------- + +/** + * Absolute URL that starts a browser self-service flow. + * Navigating the browser here (full page load, NOT fetch) lets Kratos set the + * flow cookie and redirect back to the configured `ui_url`. + * + * @param type Flow category. + * @param returnTo Optional post-success redirect target (absolute URL). + */ +export function browserFlowUrl(type: FlowType, returnTo?: string): string { + const url = new URL(`${KRATOS_URL}/self-service/${FLOW_PATH[type]}/browser`); + if (returnTo) url.searchParams.set('return_to', returnTo); + return url.toString(); +} + +/** + * Starts a self-service flow by navigating the current browser window to + * Kratos. This is a hard navigation — Kratos needs to set the flow cookie and + * issue a redirect, which a `fetch` cannot do for a browser client. + */ +export function startFlow(type: FlowType, returnTo?: string): void { + if (typeof window !== 'undefined') { + window.location.href = browserFlowUrl(type, returnTo); + } +} + +// --------------------------------------------------------------------------- +// Flow fetching +// --------------------------------------------------------------------------- + +/** + * Fetches the definition of an already-initialized flow by id. + * Called by the UI page after Kratos has redirected back with `?flow=`. + */ +export async function getFlow(type: FlowType, id: string): Promise { + const res = await fetch( + `${KRATOS_URL}/self-service/${FLOW_PATH[type]}/flows?id=${encodeURIComponent(id)}`, + { + method: 'GET', + credentials: CREDENTIALS, + headers: { Accept: 'application/json' }, + }, + ); + + if (!res.ok) { + // 403/410 mean the flow expired or the cookie is missing — the caller + // should re-initialize the flow. + throw new KratosError(res.status, 'Flow expired or not found'); + } + + return (await res.json()) as KratosFlow; +} + +// --------------------------------------------------------------------------- +// Session +// --------------------------------------------------------------------------- + +/** + * Returns the current Kratos session, or null if the browser is not logged in. + * Used for route protection and to hydrate the user store. + */ +export async function whoami(): Promise { + try { + const res = await fetch(`${KRATOS_URL}/sessions/whoami`, { + method: 'GET', + credentials: CREDENTIALS, + headers: { Accept: 'application/json' }, + }); + if (res.status === 200) { + return (await res.json()) as KratosSession; + } + // 401 = no/invalid session. + return null; + } catch { + return null; + } +} + +/** + * Initiates a browser logout flow and navigates the browser to the returned + * `logout_url`, which clears the `ory_kratos_session` cookie. Kratos then + * redirects to the configured `default_browser_return_url`. + */ +export async function logout(): Promise { + try { + const res = await fetch(`${KRATOS_URL}/self-service/logout/browser`, { + method: 'GET', + credentials: CREDENTIALS, + headers: { Accept: 'application/json' }, + }); + if (res.ok) { + const data = (await res.json()) as KratosLogoutFlow; + if (typeof window !== 'undefined' && data.logout_url) { + window.location.href = data.logout_url; + return; + } + } + } catch { + // fall through — best effort + } + // Fallback: if the logout flow could not be created (already logged out, + // network error), just send the user to the login page. + if (typeof window !== 'undefined') { + window.location.href = '/login'; + } +} + +// --------------------------------------------------------------------------- +// Form submission +// --------------------------------------------------------------------------- + +/** + * Submits a Kratos flow form to its `ui.action` URL. + * + * On success Kratos either: + * - returns 200 with a session payload (login / registration that completes + * immediately), or + * - returns a fresh flow body (e.g. recovery moved to the "code" step), or + * - returns 4xx with the same flow re-rendered with validation `messages`. + * + * Kratos browser flows redirect on hard success; with `Accept: application/json` + * it instead returns JSON, which we hand back to the caller to inspect. + * + * @returns The parsed JSON body and the HTTP status. + */ +export async function submitFlow( + action: string, + method: string, + body: Record, +): Promise<{ status: number; ok: boolean; data: unknown }> { + const res = await fetch(action, { + method: method.toUpperCase() || 'POST', + credentials: CREDENTIALS, + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + body: JSON.stringify(body), + // Kratos may 303 to the `ui_url`; we want the JSON body, not the redirect. + redirect: 'manual', + }); + + // `redirect: 'manual'` surfaces a redirect as an opaque response (status 0). + // For our flows Kratos returns JSON for both success and validation errors + // when Accept: application/json is set, so a redirect here means a hard + // success — treat it as such. + if (res.status === 0 || res.type === 'opaqueredirect') { + return { status: 200, ok: true, data: null }; + } + + const data = await res.json().catch(() => null); + return { status: res.status, ok: res.ok, data }; +} + +// --------------------------------------------------------------------------- +// Message helpers +// --------------------------------------------------------------------------- + +/** Collects all flow-level + node-level messages of the given types. */ +export function collectMessages( + flow: KratosFlow | null | undefined, + types: string[] = ['error', 'info', 'success'], +): KratosMessage[] { + if (!flow) return []; + const out: KratosMessage[] = []; + for (const m of flow.ui.messages ?? []) { + if (types.includes(m.type)) out.push(m); + } + for (const node of flow.ui.nodes) { + for (const m of node.messages ?? []) { + if (types.includes(m.type)) out.push(m); + } + } + return out; +} + +/** First error message across the whole flow, or null. */ +export function firstError(flow: KratosFlow | null | undefined): string | null { + const errors = collectMessages(flow, ['error']); + return errors.length > 0 ? errors[0].text : null; +} diff --git a/src/lib/kratos/index.ts b/src/lib/kratos/index.ts new file mode 100644 index 0000000..9263c79 --- /dev/null +++ b/src/lib/kratos/index.ts @@ -0,0 +1,29 @@ +// --------------------------------------------------------------------------- +// Ory Kratos client — public surface +// --------------------------------------------------------------------------- + +export { + KRATOS_URL, + KratosError, + browserFlowUrl, + startFlow, + getFlow, + whoami, + logout, + submitFlow, + collectMessages, + firstError, +} from './client'; + +export type { + FlowType, + KratosFlow, + KratosUiContainer, + KratosUiNode, + KratosNodeAttributes, + KratosMessage, + KratosIdentity, + KratosSession, + KratosLogoutFlow, + KratosGenericError, +} from './types'; diff --git a/src/lib/kratos/types.ts b/src/lib/kratos/types.ts new file mode 100644 index 0000000..b00af21 --- /dev/null +++ b/src/lib/kratos/types.ts @@ -0,0 +1,114 @@ +// --------------------------------------------------------------------------- +// Ory Kratos browser self-service flow types +// --------------------------------------------------------------------------- +// Subset of the Kratos API schema covering the fields the honeyDue web client +// actually consumes. See https://www.ory.sh/docs/kratos/reference/api +// --------------------------------------------------------------------------- + +/** Self-service flow categories supported by the UI. */ +export type FlowType = 'login' | 'registration' | 'recovery' | 'verification' | 'settings'; + +/** A localized message attached to a node or to the flow as a whole. */ +export interface KratosMessage { + id: number; + /** "info" | "error" | "success" */ + type: string; + text: string; + context?: Record; +} + +/** Attributes of a UI node — Kratos only emits the "input" group for our flows, + * but anchor/text/img/script groups are typed loosely for completeness. */ +export interface KratosNodeAttributes { + /** input node */ + name?: string; + type?: string; + value?: string | number | boolean; + required?: boolean; + disabled?: boolean; + autocomplete?: string; + label?: KratosMessage; + /** "text" | "anchor" | "image" | "input" | "script" */ + node_type: string; + // anchor / image + href?: string; + title?: KratosMessage; + src?: string; + // script + id?: string; +} + +/** A single renderable element of a Kratos flow form. */ +export interface KratosUiNode { + /** "default" | "password" | "oidc" | "code" | "totp" | "lookup_secret" | ... */ + group: string; + type: string; + attributes: KratosNodeAttributes; + messages: KratosMessage[]; + meta: { + label?: KratosMessage; + }; +} + +/** The renderable form definition of a flow. */ +export interface KratosUiContainer { + /** Absolute URL to POST the form to. */ + action: string; + /** "POST" (always, for these flows). */ + method: string; + nodes: KratosUiNode[]; + /** Flow-level messages (e.g. "An email containing a code has been sent"). */ + messages?: KratosMessage[]; +} + +/** Common shape shared by all self-service flow responses. */ +export interface KratosFlow { + id: string; + type: string; + expires_at?: string; + issued_at?: string; + request_url?: string; + /** Present on recovery / verification flows. */ + state?: string; + ui: KratosUiContainer; +} + +/** A Kratos identity (subset of fields used by the client). */ +export interface KratosIdentity { + id: string; + schema_id: string; + traits: Record; + verifiable_addresses?: Array<{ + value: string; + verified: boolean; + via: string; + status: string; + }>; + metadata_public?: Record | null; +} + +/** Response of GET {kratos}/sessions/whoami. */ +export interface KratosSession { + id: string; + active: boolean; + expires_at?: string; + authenticated_at?: string; + identity: KratosIdentity; +} + +/** Response of GET {kratos}/self-service/logout/browser. */ +export interface KratosLogoutFlow { + logout_url: string; + logout_token: string; +} + +/** Generic Kratos error envelope (e.g. from a 4xx with a JSON body). */ +export interface KratosGenericError { + error: { + id?: string; + code?: number; + status?: string; + reason?: string; + message?: string; + }; +} diff --git a/src/lib/kratos/use-kratos-flow.ts b/src/lib/kratos/use-kratos-flow.ts new file mode 100644 index 0000000..1a97e35 --- /dev/null +++ b/src/lib/kratos/use-kratos-flow.ts @@ -0,0 +1,88 @@ +"use client"; + +// --------------------------------------------------------------------------- +// useKratosFlow — drives the browser self-service flow lifecycle for a page +// --------------------------------------------------------------------------- +// 1. Reads `?flow=` from the URL. +// 2. If absent, hard-navigates the browser to Kratos to initialize the flow +// (Kratos sets the flow cookie and redirects back with `?flow=`). +// 3. If present, fetches the flow definition; on 403/410 (expired/missing +// cookie) it re-initializes. +// --------------------------------------------------------------------------- + +import { useCallback, useEffect, useState } from "react"; +import { useSearchParams } from "next/navigation"; + +import { getFlow, startFlow } from "./client"; +import type { FlowType, KratosFlow } from "./types"; + +interface UseKratosFlowResult { + flow: KratosFlow | null; + /** True while initializing / fetching the flow. */ + loading: boolean; + /** Non-Kratos error (e.g. network failure). */ + error: string | null; + /** Replace the current flow object (after a submit returns a fresh flow). */ + setFlow: (flow: KratosFlow) => void; + /** Re-initialize the flow from scratch. */ + reinit: () => void; +} + +export function useKratosFlow( + type: FlowType, + returnTo?: string, +): UseKratosFlowResult { + const searchParams = useSearchParams(); + const flowId = searchParams.get("flow"); + + const [flow, setFlow] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const reinit = useCallback(() => { + startFlow(type, returnTo); + }, [type, returnTo]); + + useEffect(() => { + let cancelled = false; + + // No flow id in the URL -> kick off a fresh flow (hard navigation). + if (!flowId) { + startFlow(type, returnTo); + return; + } + + // Fetch the flow definition. State is only mutated from the async + // callbacks (never synchronously in the effect body) so the initial + // `loading: true` covers the in-flight window. + getFlow(type, flowId) + .then((f) => { + if (!cancelled) { + setFlow(f); + setError(null); + setLoading(false); + } + }) + .catch((err) => { + if (cancelled) return; + // 403/410: the flow expired or the flow cookie is gone — re-init. + if ( + typeof err === "object" && + err !== null && + "status" in err && + (err.status === 403 || err.status === 410 || err.status === 404) + ) { + startFlow(type, returnTo); + return; + } + setError("Could not load the form. Please try again."); + setLoading(false); + }); + + return () => { + cancelled = true; + }; + }, [flowId, type, returnTo]); + + return { flow, loading, error, setFlow, reinit }; +} diff --git a/src/lib/types/auth.ts b/src/lib/types/auth.ts index 18323a6..22324ca 100644 --- a/src/lib/types/auth.ts +++ b/src/lib/types/auth.ts @@ -1,68 +1,24 @@ // ============================================================================ -// Auth request / response types -// Generated from: -// honeyDueAPI-go/internal/dto/requests/auth.go -// honeyDueAPI-go/internal/dto/responses/auth.go +// Auth / profile types +// ============================================================================ +// Identity (login, registration, recovery, verification, password changes, +// social sign-in) is owned by Ory Kratos — those flow types live in +// `src/lib/kratos/types.ts`, not here. +// +// What remains are the honeyDue-side *profile* types served by the Go API +// (`GET /auth/me`, `PUT /auth/profile`), authenticated by the Kratos session. // ============================================================================ // --------------------------------------------------------------------------- // Requests // --------------------------------------------------------------------------- -export interface LoginRequest { - username?: string; - email?: string; - password: string; -} - -export interface RegisterRequest { - username: string; - email: string; - password: string; - first_name?: string; - last_name?: string; -} - -export interface VerifyEmailRequest { - code: string; -} - -export interface ForgotPasswordRequest { - email: string; -} - -export interface VerifyResetCodeRequest { - email: string; - code: string; -} - -export interface ResetPasswordRequest { - reset_token: string; - new_password: string; -} - export interface UpdateProfileRequest { email?: string | null; first_name?: string | null; last_name?: string | null; } -export interface ResendVerificationRequest { - // No body needed - uses authenticated user's email -} - -export interface AppleSignInRequest { - id_token: string; - user_id: string; - email?: string | null; - first_name?: string | null; - last_name?: string | null; -} - -export interface GoogleSignInRequest { - id_token: string; -} - // --------------------------------------------------------------------------- // Responses // --------------------------------------------------------------------------- @@ -89,17 +45,6 @@ export interface UserProfileResponse { profile_picture: string; } -export interface LoginResponse { - token: string; - user: UserResponse; -} - -export interface RegisterResponse { - token: string; - user: UserResponse; - message: string; -} - export interface CurrentUserResponse { id: number; username: string; @@ -112,36 +57,6 @@ export interface CurrentUserResponse { profile?: UserProfileResponse | null; } -export interface VerifyEmailResponse { - message: string; - verified: boolean; -} - -export interface ForgotPasswordResponse { - message: string; -} - -export interface VerifyResetCodeResponse { - message: string; - reset_token: string; -} - -export interface ResetPasswordResponse { - message: string; -} - -export interface AppleSignInResponse { - token: string; - user: UserResponse; - is_new_user: boolean; -} - -export interface GoogleSignInResponse { - token: string; - user: UserResponse; - is_new_user: boolean; -} - // --------------------------------------------------------------------------- // User summary types (from responses/user.go) // --------------------------------------------------------------------------- diff --git a/src/lib/types/index.ts b/src/lib/types/index.ts index 05ecb33..4cd960d 100644 --- a/src/lib/types/index.ts +++ b/src/lib/types/index.ts @@ -6,29 +6,14 @@ export { ApiError } from "./api"; export type { ErrorResponse, MessageResponse, PaginatedResponse } from "./api"; -// Auth +// Auth / profile +// Identity flows (login, registration, recovery, ...) are owned by Ory Kratos +// — see `src/lib/kratos/types.ts`. Only honeyDue-side profile types remain. export type { - LoginRequest, - RegisterRequest, - VerifyEmailRequest, - ForgotPasswordRequest, - VerifyResetCodeRequest, - ResetPasswordRequest, UpdateProfileRequest, - ResendVerificationRequest, - AppleSignInRequest, - GoogleSignInRequest, UserResponse, UserProfileResponse, - LoginResponse, - RegisterResponse, CurrentUserResponse, - VerifyEmailResponse, - ForgotPasswordResponse, - VerifyResetCodeResponse, - ResetPasswordResponse, - AppleSignInResponse, - GoogleSignInResponse, UserSummary, UserProfileSummary, } from "./auth"; diff --git a/src/lib/validations/auth.ts b/src/lib/validations/auth.ts deleted file mode 100644 index a0b5b55..0000000 --- a/src/lib/validations/auth.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { z } from 'zod'; - -// --------------------------------------------------------------------------- -// Login -// --------------------------------------------------------------------------- - -export const loginSchema = z.object({ - username: z.string().min(1, 'Username or email is required'), - password: z.string().min(1, 'Password is required'), -}); - -export type LoginFormData = z.infer; - -// --------------------------------------------------------------------------- -// Register -// --------------------------------------------------------------------------- - -export const registerSchema = z - .object({ - first_name: z.string().min(1, 'First name is required').max(150), - last_name: z.string().min(1, 'Last name is required').max(150), - username: z - .string() - .min(3, 'Username must be at least 3 characters') - .max(150), - email: z.string().email('Invalid email address').max(254), - password: z.string().min(8, 'Password must be at least 8 characters'), - confirm_password: z.string(), - }) - .refine((data) => data.password === data.confirm_password, { - message: "Passwords don't match", - path: ['confirm_password'], - }); - -export type RegisterFormData = z.infer; - -// --------------------------------------------------------------------------- -// Verify email -// --------------------------------------------------------------------------- - -export const verifyEmailSchema = z.object({ - code: z.string().length(6, 'Code must be 6 digits'), -}); - -export type VerifyEmailFormData = z.infer; - -// --------------------------------------------------------------------------- -// Forgot password -// --------------------------------------------------------------------------- - -export const forgotPasswordSchema = z.object({ - email: z.string().email('Invalid email address'), -}); - -export type ForgotPasswordFormData = z.infer; - -// --------------------------------------------------------------------------- -// Verify reset code -// --------------------------------------------------------------------------- - -export const verifyResetCodeSchema = z.object({ - email: z.string().email(), - code: z.string().length(6, 'Code must be 6 digits'), -}); - -export type VerifyResetCodeFormData = z.infer; - -// --------------------------------------------------------------------------- -// Reset password -// --------------------------------------------------------------------------- - -export const resetPasswordSchema = z - .object({ - email: z.string().email(), - code: z.string(), - new_password: z.string().min(8, 'Password must be at least 8 characters'), - confirm_password: z.string(), - }) - .refine((data) => data.new_password === data.confirm_password, { - message: "Passwords don't match", - path: ['confirm_password'], - }); - -export type ResetPasswordFormData = z.infer; diff --git a/src/middleware.ts b/src/middleware.ts index c5e1ddb..adc8856 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -1,26 +1,56 @@ import { NextResponse } from 'next/server'; import type { NextRequest } from 'next/server'; +// --------------------------------------------------------------------------- +// Middleware — cheap cookie-presence route pre-filter +// --------------------------------------------------------------------------- +// Identity is owned by Ory Kratos. The authoritative session check is the +// `whoami` call done client-side by (and server-side by the Go API +// for every API request). Middleware only does a cheap pre-filter on the +// presence of the `ory_kratos_session` cookie so that obviously-logged-out +// users are bounced to /login without a flash of the app shell. +// +// The cookie's mere presence does NOT guarantee a valid session (it may be +// expired) — that's why still re-verifies via `whoami`. +// --------------------------------------------------------------------------- + +const SESSION_COOKIE = 'ory_kratos_session'; + export function middleware(request: NextRequest) { - const token = request.cookies.get('honeydue-token')?.value; + const hasSession = Boolean(request.cookies.get(SESSION_COOKIE)?.value); const { pathname } = request.nextUrl; - // Public paths that don't require auth - const publicPaths = ['/', '/login', '/register', '/forgot-password', '/reset-password', '/verify-email', '/demo', '/help']; - const isPublicPath = publicPaths.some(p => pathname === p || pathname.startsWith(p + '/')); + // Public paths that don't require auth. + const publicPaths = [ + '/', + '/login', + '/register', + '/forgot-password', + '/reset-password', + '/verify-email', + '/demo', + '/help', + ]; + const isPublicPath = publicPaths.some( + (p) => pathname === p || pathname.startsWith(p + '/'), + ); const isApiPath = pathname.startsWith('/api/'); - const isStaticPath = pathname.startsWith('/_next/') || pathname.startsWith('/favicon') || pathname.match(/\.(png|jpg|jpeg|gif|svg|ico|webp|woff2?|ttf|css|js)$/); + const isStaticPath = + pathname.startsWith('/_next/') || + pathname.startsWith('/favicon') || + pathname.match(/\.(png|jpg|jpeg|gif|svg|ico|webp|woff2?|ttf|css|js)$/); - // Skip middleware for API routes and static files + // Skip middleware for API routes and static files. if (isApiPath || isStaticPath) return NextResponse.next(); - // No token + protected path → redirect to login - if (!token && !isPublicPath) { + // No session cookie + protected path -> redirect to login. + if (!hasSession && !isPublicPath) { return NextResponse.redirect(new URL('/login', request.url)); } - // Has token + auth page → redirect to app - if (token && (pathname === '/login' || pathname === '/register')) { + // Has a session cookie + on the login/register pages -> send to the app. + // (AuthGate / whoami will catch the case where the cookie is stale.) + if (hasSession && (pathname === '/login' || pathname === '/register')) { return NextResponse.redirect(new URL('/app', request.url)); } diff --git a/src/stores/auth.ts b/src/stores/auth.ts index 60252e2..2acf991 100644 --- a/src/stores/auth.ts +++ b/src/stores/auth.ts @@ -1,25 +1,38 @@ import { create } from 'zustand'; import * as authApi from '@/lib/api/auth'; import { getQueryClient } from '@/lib/query/query-client'; -import { trackEvent, resetAnalytics, AnalyticsEvents } from '@/lib/analytics'; +import { resetAnalytics } from '@/lib/analytics'; +import { logout as kratosLogout, whoami } from '@/lib/kratos'; import type { UserResponse } from '@/lib/api/auth'; +// --------------------------------------------------------------------------- +// Auth store +// --------------------------------------------------------------------------- +// Identity is owned by Ory Kratos. Login / registration / recovery now happen +// on dedicated Kratos self-service flow pages, NOT through this store. What +// this store keeps is: +// - the current Kratos session state (authenticated yes/no) +// - the honeyDue-side user profile (from GET /auth/me) +// - a logout action that drives the Kratos browser logout flow +// --------------------------------------------------------------------------- + interface AuthState { + /** honeyDue-side user profile (GET /auth/me), null if not loaded / signed out. */ user: UserResponse | null; + /** True once a live Kratos session has been confirmed. */ isAuthenticated: boolean; isLoading: boolean; error: string | null; - login: (credentials: { username: string; password: string }) => Promise; - register: (data: { - username: string; - email: string; - password: string; - first_name: string; - last_name: string; - }) => Promise; - logout: () => Promise; + /** + * Confirm the Kratos session and, if valid, load the honeyDue profile. + * Call this on app startup to hydrate the store. + */ + hydrate: () => Promise; + /** Reload just the honeyDue profile (after a profile update). */ fetchUser: () => Promise; + /** Drive the Kratos browser logout flow and clear local state. */ + logout: () => Promise; clearError: () => void; } @@ -29,65 +42,47 @@ export const useAuthStore = create()((set) => ({ isLoading: false, error: null, - login: async (credentials) => { + hydrate: async () => { set({ isLoading: true, error: null }); try { - const response = await authApi.login(credentials); - trackEvent(AnalyticsEvents.USER_SIGNED_IN, { method: 'email', platform: 'web' }); - set({ user: response.user, isAuthenticated: true, isLoading: false }); - window.location.href = '/app'; - } catch (err) { - const message = - err instanceof Error ? err.message : 'Login failed. Please try again.'; - set({ isLoading: false, error: message }); - } - }, - - register: async (data) => { - set({ isLoading: true, error: null }); - try { - await authApi.register(data); - trackEvent(AnalyticsEvents.USER_REGISTERED, { method: 'email', platform: 'web' }); - set({ isLoading: false }); - } catch (err) { - const message = - err instanceof Error - ? err.message - : 'Registration failed. Please try again.'; - set({ isLoading: false, error: message }); - throw err; - } - }, - - logout: async () => { - set({ isLoading: true, error: null }); - try { - await authApi.logout(); - } catch { - // Even if logout fails server-side, clear local state - } finally { - // Clear React Query cache to prevent stale data leaking into demo mode - getQueryClient().clear(); - resetAnalytics(); - set({ - user: null, - isAuthenticated: false, - isLoading: false, - error: null, - }); - window.location.href = '/login'; - } - }, - - fetchUser: async () => { - set({ isLoading: true, error: null }); - try { - const user = await authApi.getCurrentUser(); - set({ user, isAuthenticated: true, isLoading: false }); + const session = await whoami(); + if (!session || !session.active) { + set({ user: null, isAuthenticated: false, isLoading: false }); + return; + } + // Session is valid — load the honeyDue-side profile. + try { + const user = await authApi.getCurrentUser(); + set({ user, isAuthenticated: true, isLoading: false }); + } catch { + // Session exists but the honeyDue profile call failed; still treat + // the user as authenticated so the app can render. + set({ user: null, isAuthenticated: true, isLoading: false }); + } } catch { set({ user: null, isAuthenticated: false, isLoading: false }); } }, + fetchUser: async () => { + try { + const user = await authApi.getCurrentUser(); + set({ user, isAuthenticated: true }); + } catch { + // Leave existing state; hydrate() owns auth-state transitions. + } + }, + + logout: async () => { + set({ isLoading: true, error: null }); + // Clear local app state before handing off to Kratos. + getQueryClient().clear(); + resetAnalytics(); + set({ user: null, isAuthenticated: false, isLoading: false, error: null }); + // Drives the Kratos browser logout flow, which clears the session cookie + // and redirects the browser. + await kratosLogout(); + }, + clearError: () => set({ error: null }), }));