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:
@@ -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
|
||||
@@ -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`.
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
{flow && <KratosFlowForm flow={flow} onResult={handleResult} />}
|
||||
</div>
|
||||
|
||||
<Button type="submit" className="w-full" disabled={isLoading}>
|
||||
{isLoading && <Loader2 className="animate-spin" />}
|
||||
Send reset code
|
||||
</Button>
|
||||
</form>
|
||||
</AuthFormWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ForgotPasswordPage() {
|
||||
return (
|
||||
<Suspense>
|
||||
<ForgotPasswordForm />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
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",
|
||||
});
|
||||
|
||||
async function onSubmit(data: LoginFormData) {
|
||||
clearError();
|
||||
await login({ username: data.username, password: data.password });
|
||||
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,33 +48,19 @@ 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>
|
||||
{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"
|
||||
@@ -76,25 +68,17 @@ export default function LoginPage() {
|
||||
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>
|
||||
</AuthFormWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
export default function LoginPage() {
|
||||
return (
|
||||
<Suspense>
|
||||
<LoginForm />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
function handleResult(result: { status: number; ok: boolean; data: unknown }) {
|
||||
if (result.ok) {
|
||||
trackEvent(AnalyticsEvents.USER_REGISTERED, {
|
||||
method: "kratos",
|
||||
platform: "web",
|
||||
});
|
||||
|
||||
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,
|
||||
});
|
||||
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")}
|
||||
{flow && (
|
||||
<KratosFlowForm
|
||||
flow={flow}
|
||||
onResult={handleResult}
|
||||
submitLabel="Create account"
|
||||
/>
|
||||
{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")}
|
||||
/>
|
||||
{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>
|
||||
</AuthFormWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
export default function RegisterPage() {
|
||||
return (
|
||||
<Suspense>
|
||||
<RegisterForm />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
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";
|
||||
}
|
||||
}
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
} = useForm<ResetPasswordFormData>({
|
||||
resolver: zodResolver(resetPasswordSchema),
|
||||
values: {
|
||||
email,
|
||||
code,
|
||||
new_password: "",
|
||||
confirm_password: "",
|
||||
// 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",
|
||||
),
|
||||
},
|
||||
});
|
||||
|
||||
// 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 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>
|
||||
);
|
||||
}
|
||||
: 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>
|
||||
</AuthFormWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -2,11 +2,13 @@
|
||||
|
||||
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 (
|
||||
<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]" />
|
||||
@@ -19,5 +21,6 @@ export default function AppLayout({ children }: { children: React.ReactNode }) {
|
||||
<MobileNav />
|
||||
</div>
|
||||
</DataProviderProvider>
|
||||
</AuthGate>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}</>;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
// 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);
|
||||
});
|
||||
|
||||
async function onSubmit(data: ChangePasswordFormData) {
|
||||
setSuccess(false);
|
||||
setApiError(null);
|
||||
try {
|
||||
await authApi.changePassword({
|
||||
current_password: data.current_password,
|
||||
new_password: data.new_password,
|
||||
});
|
||||
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>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
+50
-259
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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>;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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 () => {},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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";
|
||||
|
||||
@@ -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
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
+54
-59
@@ -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 });
|
||||
const session = await whoami();
|
||||
if (!session || !session.active) {
|
||||
set({ user: null, isAuthenticated: false, isLoading: false });
|
||||
return;
|
||||
}
|
||||
},
|
||||
|
||||
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 });
|
||||
// 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 }),
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user