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
+19
View File
@@ -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
+17 -4
View File
@@ -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=<id>`.
- The page reads `?flow=<id>`, fetches the flow definition (`ui.nodes` / `ui.action` / `ui.method`), and renders it generically via `<KratosFlowForm>`.
- 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: `<AuthGate>` (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 `<AuthGate>` (`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`.
+47 -66
View File
@@ -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<string | null>(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<ForgotPasswordFormData>({
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 (
<AuthFormWrapper
title="Forgot password?"
subtitle="Enter your email to receive a reset code"
subtitle="Enter your email to receive a recovery code"
footer={
<p>
<Link href="/login" className="text-primary hover:underline">
@@ -62,34 +52,25 @@ export default function ForgotPasswordPage() {
</p>
}
>
<form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-4">
{error && (
<div role="alert" aria-live="assertive" 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="email">Email</Label>
<Input
id="email"
type="email"
placeholder="you@example.com"
autoComplete="email"
aria-invalid={!!errors.email}
aria-describedby={errors.email ? "email-error" : undefined}
{...register("email")}
/>
{errors.email && (
<p id="email-error" role="alert" className="text-sm text-destructive">{errors.email.message}</p>
)}
</div>
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading && <Loader2 className="animate-spin" />}
Send reset code
</Button>
</form>
{flow && <KratosFlowForm flow={flow} onResult={handleResult} />}
</div>
</AuthFormWrapper>
);
}
export default function ForgotPasswordPage() {
return (
<Suspense>
<ForgotPasswordForm />
</Suspense>
);
}
+54 -70
View File
@@ -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<LoginFormData>({
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() {
</p>
}
>
<form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-4">
{error && (
<div role="alert" aria-live="assertive" 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="username">Username or email</Label>
<Input
id="username"
placeholder="you@example.com"
autoComplete="username"
aria-invalid={!!errors.username}
aria-describedby={errors.username ? "username-error" : undefined}
{...register("username")}
/>
{errors.username && (
<p id="username-error" role="alert" className="text-sm text-destructive">
{errors.username.message}
</p>
)}
</div>
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<Label htmlFor="password">Password</Label>
<Link
href="/forgot-password"
className="text-xs text-muted-foreground hover:text-primary"
>
Forgot password?
</Link>
</div>
<PasswordInput
id="password"
autoComplete="current-password"
aria-invalid={!!errors.password}
aria-describedby={errors.password ? "password-error" : undefined}
{...register("password")}
/>
{errors.password && (
<p id="password-error" role="alert" className="text-sm text-destructive">
{errors.password.message}
</p>
)}
</div>
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading && <Loader2 className="animate-spin" />}
Sign in
</Button>
</form>
{flow && (
<>
<KratosFlowForm flow={flow} onResult={handleResult} submitLabel="Sign in" />
<div className="text-center">
<Link
href="/forgot-password"
className="text-xs text-muted-foreground hover:text-primary"
>
Forgot password?
</Link>
</div>
</>
)}
</div>
</AuthFormWrapper>
);
}
export default function LoginPage() {
return (
<Suspense>
<LoginForm />
</Suspense>
);
}
+50 -139
View File
@@ -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<RegisterFormData>({
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() {
</p>
}
>
<form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-4">
{error && (
<div role="alert" aria-live="assertive" 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="grid grid-cols-2 gap-4">
<div className="flex flex-col gap-2">
<Label htmlFor="first_name">First name</Label>
<Input
id="first_name"
autoComplete="given-name"
aria-invalid={!!errors.first_name}
aria-describedby={errors.first_name ? "first-name-error" : undefined}
{...register("first_name")}
/>
{errors.first_name && (
<p id="first-name-error" role="alert" className="text-sm text-destructive">
{errors.first_name.message}
</p>
)}
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="last_name">Last name</Label>
<Input
id="last_name"
autoComplete="family-name"
aria-invalid={!!errors.last_name}
aria-describedby={errors.last_name ? "last-name-error" : undefined}
{...register("last_name")}
/>
{errors.last_name && (
<p id="last-name-error" role="alert" className="text-sm text-destructive">
{errors.last_name.message}
</p>
)}
</div>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="username">Username</Label>
<Input
id="username"
autoComplete="username"
aria-invalid={!!errors.username}
aria-describedby={errors.username ? "username-error" : undefined}
{...register("username")}
{flow && (
<KratosFlowForm
flow={flow}
onResult={handleResult}
submitLabel="Create account"
/>
{errors.username && (
<p id="username-error" role="alert" className="text-sm text-destructive">
{errors.username.message}
</p>
)}
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
placeholder="you@example.com"
autoComplete="email"
aria-invalid={!!errors.email}
aria-describedby={errors.email ? "email-error" : undefined}
{...register("email")}
/>
{errors.email && (
<p id="email-error" role="alert" className="text-sm text-destructive">{errors.email.message}</p>
)}
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="password">Password</Label>
<PasswordInput
id="password"
autoComplete="new-password"
aria-invalid={!!errors.password}
aria-describedby={errors.password ? "password-error" : undefined}
{...register("password")}
/>
{errors.password && (
<p id="password-error" role="alert" className="text-sm text-destructive">
{errors.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}
aria-describedby={errors.confirm_password ? "confirm-password-error" : undefined}
{...register("confirm_password")}
/>
{errors.confirm_password && (
<p id="confirm-password-error" role="alert" className="text-sm text-destructive">
{errors.confirm_password.message}
</p>
)}
</div>
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading && <Loader2 className="animate-spin" />}
Create account
</Button>
</form>
)}
</div>
</AuthFormWrapper>
);
}
export default function RegisterPage() {
return (
<Suspense>
<RegisterForm />
</Suspense>
);
}
+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>
);
}
+32 -104
View File
@@ -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<string | null>(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 (
<AuthFormWrapper
title="Verify your email"
subtitle={
email
? `Enter the 6-digit code sent to ${email}`
: "Enter the 6-digit code sent to your email"
verified
? "Your email has been verified."
: "Enter your email, then the code we send you"
}
footer={
<p>
@@ -96,43 +49,18 @@ function VerifyEmailForm() {
</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 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>
)}
<CodeInput
value={code}
onChange={handleCodeChange}
disabled={isSubmitting}
/>
<Button
type="button"
className="w-full"
disabled={code.length !== 6 || isSubmitting}
onClick={() => handleSubmit(code)}
>
{isSubmitting && <Loader2 className="animate-spin" />}
Verify email
</Button>
<div className="text-center">
<Button
type="button"
variant="ghost"
size="sm"
disabled={isResending || cooldown > 0}
onClick={handleResend}
>
{isResending && <Loader2 className="animate-spin" />}
{cooldown > 0
? `Resend code (${cooldown}s)`
: "Resend code"}
</Button>
</div>
{flow && !verified && (
<KratosFlowForm flow={flow} onResult={handleResult} />
)}
</div>
</AuthFormWrapper>
);
-76
View File
@@ -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 },
);
}
}
-50
View File
@@ -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 },
);
}
}
-60
View File
@@ -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 },
);
}
}
+11 -7
View File
@@ -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<Headers> {
const headers = new Headers();
@@ -51,11 +54,12 @@ async function buildHeaders(request: NextRequest): Promise<Headers> {
}
}
// 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;
+13 -10
View File
@@ -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 (
<DataProviderProvider value={realProvider}>
<div className="min-h-screen bg-background">
<div className="pointer-events-none fixed inset-0 bg-gradient-to-br from-primary/[0.02] via-transparent to-brand-clay/[0.02]" />
<TopBar />
<AuthGate>
<DataProviderProvider value={realProvider}>
<div className="min-h-screen bg-background">
<div className="pointer-events-none fixed inset-0 bg-gradient-to-br from-primary/[0.02] via-transparent to-brand-clay/[0.02]" />
<TopBar />
<main className="max-w-6xl mx-auto px-4 sm:px-8 py-6 lg:py-10 pb-28 md:pb-12">
{children}
</main>
<main className="max-w-6xl mx-auto px-4 sm:px-8 py-6 lg:py-10 pb-28 md:pb-12">
{children}
</main>
<MobileNav />
</div>
</DataProviderProvider>
<MobileNav />
</div>
</DataProviderProvider>
</AuthGate>
);
}
+57
View File
@@ -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 (
<div className="flex min-h-screen items-center justify-center bg-background">
<Loader2 className="size-6 animate-spin text-muted-foreground" />
</div>
);
}
return <>{children}</>;
}
+276
View File
@@ -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 <input type="hidden">
// ---------------------------------------------------------------------------
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<string | null>(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<string, string> = {};
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<HTMLFormElement>) {
e.preventDefault();
const form = e.currentTarget;
if (primarySubmit?.attributes.name) {
runSubmit(
form,
primarySubmit.attributes.name,
String(primarySubmit.attributes.value ?? ""),
);
} else {
runSubmit(form);
}
}
return (
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
{/* Hidden inputs (csrf_token, flow id, method markers, ...) */}
{hiddenNodes.map((node, i) => (
<input
key={`${node.attributes.name}-${i}`}
type="hidden"
name={node.attributes.name}
defaultValue={String(node.attributes.value ?? "")}
/>
))}
{/* Visible input fields */}
{fieldNodes.map((node) => (
<FieldNode key={node.attributes.name} node={node} />
))}
{/* Primary submit */}
{primarySubmit && (
<Button type="submit" className="w-full" disabled={submitting}>
{submitting && activeSubmit === String(primarySubmit.attributes.value ?? "") && (
<Loader2 className="animate-spin" />
)}
{submitLabel ||
nodeLabel(primarySubmit) ||
"Continue"}
</Button>
)}
{/* Social sign-in (oidc) */}
{oidcNodes.length > 0 && (
<div className="flex flex-col gap-2">
{(fieldNodes.length > 0 || primarySubmit) && (
<div className="relative my-1 text-center">
<span className="relative z-10 bg-card px-2 text-xs text-muted-foreground">
or continue with
</span>
<span className="absolute left-0 top-1/2 h-px w-full -translate-y-1/2 bg-border" />
</div>
)}
{oidcNodes.map((node) => (
<Button
key={String(node.attributes.value)}
type="submit"
variant="outline"
className="w-full"
disabled={submitting}
formNoValidate
onClick={(e) => {
e.preventDefault();
runSubmit(
e.currentTarget.form as HTMLFormElement,
node.attributes.name,
String(node.attributes.value ?? ""),
);
}}
>
{submitting && activeSubmit === String(node.attributes.value ?? "") && (
<Loader2 className="animate-spin" />
)}
{nodeLabel(node) || `Continue with ${providerName(node)}`}
</Button>
))}
</div>
)}
</form>
);
}
// ---------------------------------------------------------------------------
// 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 (
<div className="flex flex-col gap-2">
{label && <Label htmlFor={fieldId}>{label}</Label>}
{isPassword ? (
<PasswordInput
id={fieldId}
name={name}
required={node.attributes.required}
disabled={node.attributes.disabled}
autoComplete={node.attributes.autocomplete ?? "current-password"}
aria-invalid={hasError}
aria-describedby={hasError ? `${fieldId}-error` : undefined}
/>
) : (
<Input
id={fieldId}
name={name}
type={type}
required={node.attributes.required}
disabled={node.attributes.disabled}
autoComplete={node.attributes.autocomplete}
defaultValue={String(node.attributes.value ?? "")}
aria-invalid={hasError}
aria-describedby={hasError ? `${fieldId}-error` : undefined}
/>
)}
{errors.map((m, i) => (
<p
key={m.id ?? i}
id={`${fieldId}-error`}
role="alert"
className="text-sm text-destructive"
>
{m.text}
</p>
))}
</div>
);
}
+70
View File
@@ -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 <CircleAlert className="size-4 shrink-0" />;
if (type === "success") return <CheckCircle2 className="size-4 shrink-0" />;
return <Info className="size-4 shrink-0" />;
}
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 (
<div className="flex flex-col gap-2">
{error && (
<div
role="alert"
aria-live="assertive"
className="flex items-center gap-2 rounded-md bg-destructive/10 px-3 py-2 text-sm text-destructive"
>
<CircleAlert className="size-4 shrink-0" />
{error}
</div>
)}
{list.map((m, i) => (
<div
key={m.id ?? i}
role={m.type === "error" ? "alert" : "status"}
aria-live={m.type === "error" ? "assertive" : "polite"}
className={`flex items-center gap-2 rounded-md px-3 py-2 text-sm ${variantClasses(m.type)}`}
>
<Icon type={m.type} />
{m.text}
</div>
))}
</div>
);
}
-107
View File
@@ -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<HTMLInputElement>
) {
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 (
<div className={cn("flex gap-2 justify-center", className)}>
{digits.map((digit, i) => (
<Input
key={i}
ref={(el) => {
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"
/>
))}
</div>
);
}
+6 -7
View File
@@ -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 (
@@ -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<typeof changePasswordSchema>;
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<string | null>(null);
const [flow, setFlow] = useState<KratosFlow | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const {
register,
handleSubmit,
reset,
formState: { errors, isSubmitting },
} = useForm<ChangePasswordFormData>({
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 (
<Card>
<CardHeader>
<CardTitle>Change Password</CardTitle>
<CardDescription>Update your password to keep your account secure.</CardDescription>
<CardDescription>
Update your password to keep your account secure.
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
{apiError && (
<div className="rounded-md bg-destructive/10 px-3 py-2 text-sm text-destructive">
{apiError}
</div>
)}
{success && (
<div className="rounded-md bg-green-500/10 px-3 py-2 text-sm text-green-700 dark:text-green-400 flex items-center gap-2">
<Check className="size-4" />
Password changed successfully.
<div className="flex flex-col gap-4">
<KratosMessages flow={flow} error={error} />
{loading && !flow && (
<div className="flex items-center justify-center py-4 text-muted-foreground">
<Loader2 className="animate-spin" />
</div>
)}
<FormField label="Current password" htmlFor="current_password" error={errors.current_password?.message} required>
<PasswordInput
id="current_password"
autoComplete="current-password"
aria-invalid={!!errors.current_password}
{...register("current_password")}
{passwordFlow && (
<KratosFlowForm
flow={passwordFlow}
onResult={handleResult}
submitLabel="Update Password"
/>
</FormField>
<FormField label="New password" htmlFor="new_password" error={errors.new_password?.message} required>
<PasswordInput
id="new_password"
autoComplete="new-password"
aria-invalid={!!errors.new_password}
{...register("new_password")}
/>
</FormField>
<FormField label="Confirm new password" htmlFor="confirm_password" error={errors.confirm_password?.message} required>
<PasswordInput
id="confirm_password"
autoComplete="new-password"
aria-invalid={!!errors.confirm_password}
{...register("confirm_password")}
/>
</FormField>
<div className="flex justify-end">
<Button type="submit" disabled={isSubmitting}>
{isSubmitting && <Loader2 className="animate-spin" />}
Update Password
</Button>
</div>
</form>
)}
</div>
</CardContent>
</Card>
);
+50 -259
View File
@@ -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<UserResponse> {
return apiFetch<UserResponse>('/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<UserResponse> {
return apiFetch<UserResponse>('/auth/profile/', {
method: 'PUT',
body: JSON.stringify(data),
});
}
export interface MessageResponse {
message: string;
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
async function handleResponse<T>(res: Response): Promise<T> {
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<LoginResponse> {
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Timezone': timezone(),
},
body: JSON.stringify(credentials),
});
return handleResponse<LoginResponse>(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<RegisterResponse> {
const res = await fetch('/api/proxy/auth/register', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Timezone': timezone(),
},
body: JSON.stringify(data),
});
return handleResponse<RegisterResponse>(res);
}
/**
* Log out. Clears the httpOnly cookie and invalidates the token server-side.
*/
export async function logout(): Promise<MessageResponse> {
const res = await fetch('/api/auth/logout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
});
return handleResponse<MessageResponse>(res);
}
/**
* Get the currently authenticated user.
*/
export async function getCurrentUser(): Promise<UserResponse> {
const res = await fetch('/api/auth/me', {
headers: { 'Content-Type': 'application/json' },
});
return handleResponse<UserResponse>(res);
}
/**
* Update the authenticated user's profile.
*/
export async function updateProfile(
data: UpdateProfileRequest,
): Promise<UserResponse> {
const res = await fetch('/api/proxy/auth/profile', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'X-Timezone': timezone(),
},
body: JSON.stringify(data),
});
return handleResponse<UserResponse>(res);
}
/**
* Verify the user's email with a 6-digit code.
*/
export async function verifyEmail(
data: VerifyEmailRequest,
): Promise<VerifyEmailResponse> {
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<VerifyEmailResponse>(res);
}
/**
* Resend the email verification code.
*/
export async function resendVerification(): Promise<MessageResponse> {
const res = await fetch('/api/proxy/auth/resend-verification', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Timezone': timezone(),
},
});
return handleResponse<MessageResponse>(res);
}
/**
* Request a password reset email.
*/
export async function forgotPassword(
data: ForgotPasswordRequest,
): Promise<MessageResponse> {
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<MessageResponse>(res);
}
/**
* Verify the 6-digit reset code; returns a reset token.
*/
export async function verifyResetCode(
data: VerifyResetCodeRequest,
): Promise<VerifyResetCodeResponse> {
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<VerifyResetCodeResponse>(res);
}
/**
* Reset password using the token from `verifyResetCode`.
*/
export async function resetPassword(
data: ResetPasswordRequest,
): Promise<MessageResponse> {
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<MessageResponse>(res);
}
/**
* Change the authenticated user's password.
*/
export async function changePassword(
data: { current_password: string; new_password: string },
): Promise<MessageResponse> {
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<MessageResponse>(res);
}
/**
* Delete the authenticated user's account permanently.
*/
export async function deleteAccount(): Promise<MessageResponse> {
const res = await fetch('/api/proxy/auth/delete-account', {
export function deleteAccount(): Promise<MessageResponse> {
return apiFetch<MessageResponse>('/auth/account/', {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
'X-Timezone': timezone(),
},
});
return handleResponse<MessageResponse>(res);
}
+19 -10
View File
@@ -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<T>(
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<T>(
// ---------------------------------------------------------------------------
/**
* 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<T>(
// (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<T>(
...(options.headers as Record<string, string>),
};
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) {
+5 -1
View File
@@ -153,7 +153,11 @@ export interface DataProvider {
auth: {
getCurrentUser(): Promise<UserResponse>;
logout(): Promise<MessageResponse>;
/**
* Real mode: drives the Ory Kratos browser logout flow (clears the
* session cookie and navigates the browser). Demo mode: a no-op.
*/
logout(): Promise<void>;
};
}
+3 -1
View File
@@ -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 () => {},
},
};
+3 -1
View File
@@ -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(),
},
};
+12 -4
View File
@@ -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');
}
},
});
+240
View File
@@ -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=<id>`.
// 2. The UI page reads `?flow=<id>` 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<FlowType, string> = {
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=<id>`.
*/
export async function getFlow(type: FlowType, id: string): Promise<KratosFlow> {
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<KratosSession | null> {
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<void> {
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<string, string>,
): 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;
}
+29
View File
@@ -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';
+114
View File
@@ -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<string, unknown>;
}
/** 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<string, unknown>;
verifiable_addresses?: Array<{
value: string;
verified: boolean;
via: string;
status: string;
}>;
metadata_public?: Record<string, unknown> | 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;
};
}
+88
View File
@@ -0,0 +1,88 @@
"use client";
// ---------------------------------------------------------------------------
// useKratosFlow — drives the browser self-service flow lifecycle for a page
// ---------------------------------------------------------------------------
// 1. Reads `?flow=<id>` 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=<id>`).
// 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<KratosFlow | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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 };
}
+8 -93
View File
@@ -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)
// ---------------------------------------------------------------------------
+3 -18
View File
@@ -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";
-84
View File
@@ -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<typeof loginSchema>;
// ---------------------------------------------------------------------------
// 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<typeof registerSchema>;
// ---------------------------------------------------------------------------
// Verify email
// ---------------------------------------------------------------------------
export const verifyEmailSchema = z.object({
code: z.string().length(6, 'Code must be 6 digits'),
});
export type VerifyEmailFormData = z.infer<typeof verifyEmailSchema>;
// ---------------------------------------------------------------------------
// Forgot password
// ---------------------------------------------------------------------------
export const forgotPasswordSchema = z.object({
email: z.string().email('Invalid email address'),
});
export type ForgotPasswordFormData = z.infer<typeof forgotPasswordSchema>;
// ---------------------------------------------------------------------------
// 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<typeof verifyResetCodeSchema>;
// ---------------------------------------------------------------------------
// 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<typeof resetPasswordSchema>;
+40 -10
View File
@@ -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 <AuthGate> (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 <AuthGate> 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));
}
+58 -63
View File
@@ -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<void>;
register: (data: {
username: string;
email: string;
password: string;
first_name: string;
last_name: string;
}) => Promise<void>;
logout: () => Promise<void>;
/**
* Confirm the Kratos session and, if valid, load the honeyDue profile.
* Call this on app startup to hydrate the store.
*/
hydrate: () => Promise<void>;
/** Reload just the honeyDue profile (after a profile update). */
fetchUser: () => Promise<void>;
/** Drive the Kratos browser logout flow and clear local state. */
logout: () => Promise<void>;
clearError: () => void;
}
@@ -29,65 +42,47 @@ export const useAuthStore = create<AuthState>()((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 }),
}));