Replace hand-rolled auth with Ory Kratos browser flows

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) <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-05-18 18:16:49 -05:00
parent f77f913ee8
commit 42e7bedea4
33 changed files with 1474 additions and 1504 deletions
+59 -168
View File
@@ -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<Step>("code");
const [code, setCode] = useState("");
const [resetToken, setResetToken] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const {
register,
handleSubmit,
formState: { errors },
} = useForm<ResetPasswordFormData>({
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 (
<AuthFormWrapper
title="Enter reset code"
subtitle={
email
? `Enter the 6-digit code sent to ${email}`
: "Enter the 6-digit code sent to your email"
}
footer={
<p>
<Link href="/login" className="text-primary hover:underline">
Back to login
</Link>
</p>
}
>
<div className="flex flex-col gap-6">
{error && (
<div className="rounded-md bg-destructive/10 px-3 py-2 text-sm text-destructive">
{error}
</div>
)}
<CodeInput
value={code}
onChange={handleCodeChange}
disabled={isLoading}
/>
<Button
type="button"
className="w-full"
disabled={code.length !== 6 || isLoading}
onClick={() => handleVerifyCode(code)}
>
{isLoading && <Loader2 className="animate-spin" />}
Verify code
</Button>
</div>
</AuthFormWrapper>
);
}
// 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 (
<AuthFormWrapper
title="Set new password"
subtitle="Enter your new password below"
subtitle="Choose a new password for your account"
footer={
<p>
<Link href="/login" className="text-primary hover:underline">
@@ -150,51 +69,23 @@ function ResetPasswordForm() {
</p>
}
>
<form
onSubmit={handleSubmit(onSubmitPassword)}
className="flex flex-col gap-4"
>
{error && (
<div className="rounded-md bg-destructive/10 px-3 py-2 text-sm text-destructive">
{error}
<div className="flex flex-col gap-4">
<KratosMessages flow={flow} error={error} />
{loading && !flow && (
<div className="flex items-center justify-center py-6 text-muted-foreground">
<Loader2 className="animate-spin" />
</div>
)}
<div className="flex flex-col gap-2">
<Label htmlFor="new_password">New password</Label>
<PasswordInput
id="new_password"
autoComplete="new-password"
aria-invalid={!!errors.new_password}
{...register("new_password")}
{passwordOnlyFlow && (
<KratosFlowForm
flow={passwordOnlyFlow}
onResult={handleResult}
submitLabel="Update password"
/>
{errors.new_password && (
<p className="text-sm text-destructive">
{errors.new_password.message}
</p>
)}
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="confirm_password">Confirm password</Label>
<PasswordInput
id="confirm_password"
autoComplete="new-password"
aria-invalid={!!errors.confirm_password}
{...register("confirm_password")}
/>
{errors.confirm_password && (
<p className="text-sm text-destructive">
{errors.confirm_password.message}
</p>
)}
</div>
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading && <Loader2 className="animate-spin" />}
Reset password
</Button>
</form>
)}
</div>
</AuthFormWrapper>
);
}