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)
|
Browser → Next.js page (client component)
|
||||||
→ apiFetch("/tasks/") → /api/proxy/tasks (Next.js route handler)
|
→ 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
|
### Directory Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
src/
|
src/
|
||||||
├── app/
|
├── 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
|
│ ├── api/proxy/ # Catch-all proxy to Go API
|
||||||
│ ├── app/ # Authenticated app pages
|
│ ├── app/ # Authenticated app pages
|
||||||
│ │ ├── contractors/ # Contractor CRUD
|
│ │ ├── 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`.
|
**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
|
## Conventions
|
||||||
|
|
||||||
@@ -125,9 +135,12 @@ src/
|
|||||||
|----------|-------------|---------|
|
|----------|-------------|---------|
|
||||||
| `NEXT_PUBLIC_API_URL` | Go API URL (client-side) | `https://honeyDue.treytartt.com/api` |
|
| `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` |
|
| `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_KEY` | PostHog analytics key | — |
|
||||||
| `NEXT_PUBLIC_POSTHOG_HOST` | PostHog host | — |
|
| `NEXT_PUBLIC_POSTHOG_HOST` | PostHog host | — |
|
||||||
|
|
||||||
|
See `.env.example` for the full list.
|
||||||
|
|
||||||
## Common Tasks
|
## 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`.
|
**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";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { Suspense } from "react";
|
||||||
import Link from "next/link";
|
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 { 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 { AuthFormWrapper } from "@/components/forms/auth-form-wrapper";
|
||||||
import {
|
import { KratosFlowForm } from "@/components/auth/kratos-flow-form";
|
||||||
forgotPasswordSchema,
|
import { KratosMessages } from "@/components/auth/kratos-messages";
|
||||||
type ForgotPasswordFormData,
|
import { useKratosFlow } from "@/lib/kratos/use-kratos-flow";
|
||||||
} from "@/lib/validations/auth";
|
import type { KratosFlow } from "@/lib/kratos";
|
||||||
import * as authApi from "@/lib/api/auth";
|
|
||||||
import { ApiError } from "@/lib/api/client";
|
|
||||||
|
|
||||||
export default function ForgotPasswordPage() {
|
// ---------------------------------------------------------------------------
|
||||||
const router = useRouter();
|
// Forgot password — Ory Kratos `recovery` browser self-service flow
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
// ---------------------------------------------------------------------------
|
||||||
const [error, setError] = useState<string | null>(null);
|
// 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 {
|
function ForgotPasswordForm() {
|
||||||
register,
|
const { flow, loading, error, setFlow } = useKratosFlow("recovery");
|
||||||
handleSubmit,
|
|
||||||
formState: { errors },
|
|
||||||
} = useForm<ForgotPasswordFormData>({
|
|
||||||
resolver: zodResolver(forgotPasswordSchema),
|
|
||||||
});
|
|
||||||
|
|
||||||
async function onSubmit(data: ForgotPasswordFormData) {
|
function handleResult(result: { status: number; ok: boolean; data: unknown }) {
|
||||||
setIsLoading(true);
|
// A hard success (2xx with no body) means Kratos established a recovery
|
||||||
setError(null);
|
// session and is redirecting the browser to the settings flow. With
|
||||||
try {
|
// `redirect: 'manual'` the redirect surfaces as ok=true / data=null —
|
||||||
await authApi.forgotPassword({ email: data.email });
|
// navigate to settings so the user can pick a new password.
|
||||||
router.push(
|
if (result.ok && !result.data) {
|
||||||
`/reset-password?email=${encodeURIComponent(data.email)}`
|
window.location.href = "/settings";
|
||||||
);
|
return;
|
||||||
} catch (err) {
|
}
|
||||||
const message =
|
// Otherwise Kratos returned a flow body: either the same flow advanced to
|
||||||
err instanceof ApiError
|
// the "code" step, or the same step re-rendered with validation messages.
|
||||||
? err.message
|
if (result.data && typeof result.data === "object" && "ui" in result.data) {
|
||||||
: "Failed to send reset code. Please try again.";
|
setFlow(result.data as KratosFlow);
|
||||||
setError(message);
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthFormWrapper
|
<AuthFormWrapper
|
||||||
title="Forgot password?"
|
title="Forgot password?"
|
||||||
subtitle="Enter your email to receive a reset code"
|
subtitle="Enter your email to receive a recovery code"
|
||||||
footer={
|
footer={
|
||||||
<p>
|
<p>
|
||||||
<Link href="/login" className="text-primary hover:underline">
|
<Link href="/login" className="text-primary hover:underline">
|
||||||
@@ -62,34 +52,25 @@ export default function ForgotPasswordPage() {
|
|||||||
</p>
|
</p>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
{error && (
|
<KratosMessages flow={flow} error={error} />
|
||||||
<div role="alert" aria-live="assertive" className="rounded-md bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
|
||||||
{error}
|
{loading && !flow && (
|
||||||
|
<div className="flex items-center justify-center py-6 text-muted-foreground">
|
||||||
|
<Loader2 className="animate-spin" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex flex-col gap-2">
|
{flow && <KratosFlowForm flow={flow} onResult={handleResult} />}
|
||||||
<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>
|
||||||
|
|
||||||
<Button type="submit" className="w-full" disabled={isLoading}>
|
|
||||||
{isLoading && <Loader2 className="animate-spin" />}
|
|
||||||
Send reset code
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
</AuthFormWrapper>
|
</AuthFormWrapper>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default function ForgotPasswordPage() {
|
||||||
|
return (
|
||||||
|
<Suspense>
|
||||||
|
<ForgotPasswordForm />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,32 +1,38 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { Suspense } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useForm } from "react-hook-form";
|
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
|
||||||
import { Loader2 } from "lucide-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 { AuthFormWrapper } from "@/components/forms/auth-form-wrapper";
|
import { AuthFormWrapper } from "@/components/forms/auth-form-wrapper";
|
||||||
import { PasswordInput } from "@/components/forms/password-input";
|
import { KratosFlowForm } from "@/components/auth/kratos-flow-form";
|
||||||
import { loginSchema, type LoginFormData } from "@/lib/validations/auth";
|
import { KratosMessages } from "@/components/auth/kratos-messages";
|
||||||
import { useAuthStore } from "@/stores/auth";
|
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 {
|
function LoginForm() {
|
||||||
register,
|
const { flow, loading, error, setFlow } = useKratosFlow("login");
|
||||||
handleSubmit,
|
|
||||||
formState: { errors },
|
function handleResult(result: { status: number; ok: boolean; data: unknown }) {
|
||||||
} = useForm<LoginFormData>({
|
if (result.ok) {
|
||||||
resolver: zodResolver(loginSchema),
|
// Kratos completed the login (set the `ory_kratos_session` cookie).
|
||||||
|
trackEvent(AnalyticsEvents.USER_SIGNED_IN, {
|
||||||
|
method: "kratos",
|
||||||
|
platform: "web",
|
||||||
});
|
});
|
||||||
|
window.location.href = "/app";
|
||||||
async function onSubmit(data: LoginFormData) {
|
return;
|
||||||
clearError();
|
}
|
||||||
await login({ username: data.username, password: data.password });
|
// 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 (
|
return (
|
||||||
@@ -42,33 +48,19 @@ export default function LoginPage() {
|
|||||||
</p>
|
</p>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
{error && (
|
<KratosMessages flow={flow} error={error} />
|
||||||
<div role="alert" aria-live="assertive" className="rounded-md bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
|
||||||
{error}
|
{loading && !flow && (
|
||||||
|
<div className="flex items-center justify-center py-6 text-muted-foreground">
|
||||||
|
<Loader2 className="animate-spin" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex flex-col gap-2">
|
{flow && (
|
||||||
<Label htmlFor="username">Username or email</Label>
|
<>
|
||||||
<Input
|
<KratosFlowForm flow={flow} onResult={handleResult} submitLabel="Sign in" />
|
||||||
id="username"
|
<div className="text-center">
|
||||||
placeholder="you@example.com"
|
|
||||||
autoComplete="username"
|
|
||||||
aria-invalid={!!errors.username}
|
|
||||||
aria-describedby={errors.username ? "username-error" : undefined}
|
|
||||||
{...register("username")}
|
|
||||||
/>
|
|
||||||
{errors.username && (
|
|
||||||
<p id="username-error" role="alert" className="text-sm text-destructive">
|
|
||||||
{errors.username.message}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<Label htmlFor="password">Password</Label>
|
|
||||||
<Link
|
<Link
|
||||||
href="/forgot-password"
|
href="/forgot-password"
|
||||||
className="text-xs text-muted-foreground hover:text-primary"
|
className="text-xs text-muted-foreground hover:text-primary"
|
||||||
@@ -76,25 +68,17 @@ export default function LoginPage() {
|
|||||||
Forgot password?
|
Forgot password?
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<Button type="submit" className="w-full" disabled={isLoading}>
|
|
||||||
{isLoading && <Loader2 className="animate-spin" />}
|
|
||||||
Sign in
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
</AuthFormWrapper>
|
</AuthFormWrapper>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
return (
|
||||||
|
<Suspense>
|
||||||
|
<LoginForm />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,46 +1,42 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { Suspense } from "react";
|
||||||
import Link from "next/link";
|
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 { 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 { AuthFormWrapper } from "@/components/forms/auth-form-wrapper";
|
||||||
import { PasswordInput } from "@/components/forms/password-input";
|
import { KratosFlowForm } from "@/components/auth/kratos-flow-form";
|
||||||
import { registerSchema, type RegisterFormData } from "@/lib/validations/auth";
|
import { KratosMessages } from "@/components/auth/kratos-messages";
|
||||||
import { useAuthStore } from "@/stores/auth";
|
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();
|
// Registration — Ory Kratos `registration` browser self-service flow
|
||||||
const { register: registerUser, isLoading, error, clearError } = useAuthStore();
|
// ---------------------------------------------------------------------------
|
||||||
|
// 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 {
|
function RegisterForm() {
|
||||||
register,
|
const { flow, loading, error, setFlow } = useKratosFlow("registration");
|
||||||
handleSubmit,
|
|
||||||
formState: { errors },
|
function handleResult(result: { status: number; ok: boolean; data: unknown }) {
|
||||||
} = useForm<RegisterFormData>({
|
if (result.ok) {
|
||||||
resolver: zodResolver(registerSchema),
|
trackEvent(AnalyticsEvents.USER_REGISTERED, {
|
||||||
|
method: "kratos",
|
||||||
|
platform: "web",
|
||||||
});
|
});
|
||||||
|
// Depending on Kratos config, registration may either log the user in
|
||||||
async function onSubmit(data: RegisterFormData) {
|
// immediately (session cookie set) or require email verification first.
|
||||||
clearError();
|
// `/app` works in both cases — middleware / whoami will route the user
|
||||||
try {
|
// to verification if a session is not yet active.
|
||||||
await registerUser({
|
window.location.href = "/app";
|
||||||
first_name: data.first_name,
|
return;
|
||||||
last_name: data.last_name,
|
}
|
||||||
username: data.username,
|
if (result.data && typeof result.data === "object" && "ui" in result.data) {
|
||||||
email: data.email,
|
setFlow(result.data as KratosFlow);
|
||||||
password: data.password,
|
|
||||||
});
|
|
||||||
router.push(
|
|
||||||
`/verify-email?email=${encodeURIComponent(data.email)}`
|
|
||||||
);
|
|
||||||
} catch {
|
|
||||||
// Error is already set in the store
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,116 +53,31 @@ export default function RegisterPage() {
|
|||||||
</p>
|
</p>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
{error && (
|
<KratosMessages flow={flow} error={error} />
|
||||||
<div role="alert" aria-live="assertive" className="rounded-md bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
|
||||||
{error}
|
{loading && !flow && (
|
||||||
|
<div className="flex items-center justify-center py-6 text-muted-foreground">
|
||||||
|
<Loader2 className="animate-spin" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
{flow && (
|
||||||
<div className="flex flex-col gap-2">
|
<KratosFlowForm
|
||||||
<Label htmlFor="first_name">First name</Label>
|
flow={flow}
|
||||||
<Input
|
onResult={handleResult}
|
||||||
id="first_name"
|
submitLabel="Create account"
|
||||||
autoComplete="given-name"
|
|
||||||
aria-invalid={!!errors.first_name}
|
|
||||||
aria-describedby={errors.first_name ? "first-name-error" : undefined}
|
|
||||||
{...register("first_name")}
|
|
||||||
/>
|
/>
|
||||||
{errors.first_name && (
|
|
||||||
<p id="first-name-error" role="alert" className="text-sm text-destructive">
|
|
||||||
{errors.first_name.message}
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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>
|
</AuthFormWrapper>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default function RegisterPage() {
|
||||||
|
return (
|
||||||
|
<Suspense>
|
||||||
|
<RegisterForm />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,147 +1,66 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Suspense, useState } from "react";
|
import { Suspense } from "react";
|
||||||
import Link from "next/link";
|
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 { 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 { AuthFormWrapper } from "@/components/forms/auth-form-wrapper";
|
||||||
import { PasswordInput } from "@/components/forms/password-input";
|
import { KratosFlowForm } from "@/components/auth/kratos-flow-form";
|
||||||
import { CodeInput } from "@/components/forms/code-input";
|
import { KratosMessages } from "@/components/auth/kratos-messages";
|
||||||
import { resetPasswordSchema, type ResetPasswordFormData } from "@/lib/validations/auth";
|
import { useKratosFlow } from "@/lib/kratos/use-kratos-flow";
|
||||||
import * as authApi from "@/lib/api/auth";
|
import type { KratosFlow, KratosUiNode } from "@/lib/kratos";
|
||||||
import { ApiError } from "@/lib/api/client";
|
|
||||||
|
|
||||||
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() {
|
function ResetPasswordForm() {
|
||||||
const router = useRouter();
|
const { flow, loading, error, setFlow } = useKratosFlow("settings");
|
||||||
const searchParams = useSearchParams();
|
|
||||||
const email = searchParams.get("email") ?? "";
|
|
||||||
|
|
||||||
const [step, setStep] = useState<Step>("code");
|
function handleResult(result: { status: number; ok: boolean; data: unknown }) {
|
||||||
const [code, setCode] = useState("");
|
if (result.data && typeof result.data === "object" && "ui" in result.data) {
|
||||||
const [resetToken, setResetToken] = useState("");
|
const next = result.data as KratosFlow;
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
setFlow(next);
|
||||||
const [error, setError] = useState<string | null>(null);
|
// 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 {
|
// Only render password-related nodes (csrf/method markers included).
|
||||||
register,
|
const passwordOnlyFlow: KratosFlow | null = flow
|
||||||
handleSubmit,
|
? {
|
||||||
formState: { errors },
|
...flow,
|
||||||
} = useForm<ResetPasswordFormData>({
|
ui: {
|
||||||
resolver: zodResolver(resetPasswordSchema),
|
...flow.ui,
|
||||||
values: {
|
nodes: flow.ui.nodes.filter(
|
||||||
email,
|
(n: KratosUiNode) => n.group === "password" || n.group === "default",
|
||||||
code,
|
),
|
||||||
new_password: "",
|
|
||||||
confirm_password: "",
|
|
||||||
},
|
},
|
||||||
});
|
|
||||||
|
|
||||||
// Step 1: Verify the 6-digit code
|
|
||||||
async function handleVerifyCode(submittedCode: string) {
|
|
||||||
if (submittedCode.length !== 6 || isLoading) return;
|
|
||||||
setIsLoading(true);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
const result = await authApi.verifyResetCode({
|
|
||||||
email,
|
|
||||||
code: submittedCode,
|
|
||||||
});
|
|
||||||
setResetToken(result.reset_token);
|
|
||||||
setStep("password");
|
|
||||||
} catch (err) {
|
|
||||||
const message =
|
|
||||||
err instanceof ApiError
|
|
||||||
? err.message
|
|
||||||
: "Invalid code. Please try again.";
|
|
||||||
setError(message);
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function 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 (
|
return (
|
||||||
<AuthFormWrapper
|
<AuthFormWrapper
|
||||||
title="Set new password"
|
title="Set new password"
|
||||||
subtitle="Enter your new password below"
|
subtitle="Choose a new password for your account"
|
||||||
footer={
|
footer={
|
||||||
<p>
|
<p>
|
||||||
<Link href="/login" className="text-primary hover:underline">
|
<Link href="/login" className="text-primary hover:underline">
|
||||||
@@ -150,51 +69,23 @@ function ResetPasswordForm() {
|
|||||||
</p>
|
</p>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<form
|
<div className="flex flex-col gap-4">
|
||||||
onSubmit={handleSubmit(onSubmitPassword)}
|
<KratosMessages flow={flow} error={error} />
|
||||||
className="flex flex-col gap-4"
|
|
||||||
>
|
{loading && !flow && (
|
||||||
{error && (
|
<div className="flex items-center justify-center py-6 text-muted-foreground">
|
||||||
<div className="rounded-md bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
<Loader2 className="animate-spin" />
|
||||||
{error}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex flex-col gap-2">
|
{passwordOnlyFlow && (
|
||||||
<Label htmlFor="new_password">New password</Label>
|
<KratosFlowForm
|
||||||
<PasswordInput
|
flow={passwordOnlyFlow}
|
||||||
id="new_password"
|
onResult={handleResult}
|
||||||
autoComplete="new-password"
|
submitLabel="Update password"
|
||||||
aria-invalid={!!errors.new_password}
|
|
||||||
{...register("new_password")}
|
|
||||||
/>
|
/>
|
||||||
{errors.new_password && (
|
|
||||||
<p className="text-sm text-destructive">
|
|
||||||
{errors.new_password.message}
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
</AuthFormWrapper>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,91 +2,44 @@
|
|||||||
|
|
||||||
import { Suspense } from "react";
|
import { Suspense } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter, useSearchParams } from "next/navigation";
|
|
||||||
import { useState, useEffect, useCallback } from "react";
|
|
||||||
import { Loader2 } from "lucide-react";
|
import { Loader2 } from "lucide-react";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { AuthFormWrapper } from "@/components/forms/auth-form-wrapper";
|
import { AuthFormWrapper } from "@/components/forms/auth-form-wrapper";
|
||||||
import { CodeInput } from "@/components/forms/code-input";
|
import { KratosFlowForm } from "@/components/auth/kratos-flow-form";
|
||||||
import * as authApi from "@/lib/api/auth";
|
import { KratosMessages } from "@/components/auth/kratos-messages";
|
||||||
import { ApiError } from "@/lib/api/client";
|
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() {
|
function VerifyEmailForm() {
|
||||||
const router = useRouter();
|
const { flow, loading, error, setFlow } = useKratosFlow("verification");
|
||||||
const searchParams = useSearchParams();
|
|
||||||
const email = searchParams.get("email") ?? "";
|
|
||||||
|
|
||||||
const [code, setCode] = useState("");
|
function handleResult(result: { status: number; ok: boolean; data: unknown }) {
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
// Kratos returns the same flow re-rendered for every step (email -> code,
|
||||||
const [isResending, setIsResending] = useState(false);
|
// and the final "passed_challenge" success). Just re-render it.
|
||||||
const [error, setError] = useState<string | null>(null);
|
if (result.data && typeof result.data === "object" && "ui" in result.data) {
|
||||||
const [cooldown, setCooldown] = useState(0);
|
setFlow(result.data as KratosFlow);
|
||||||
|
|
||||||
// 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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleResend() {
|
const verified = flow?.state === "passed_challenge";
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthFormWrapper
|
<AuthFormWrapper
|
||||||
title="Verify your email"
|
title="Verify your email"
|
||||||
subtitle={
|
subtitle={
|
||||||
email
|
verified
|
||||||
? `Enter the 6-digit code sent to ${email}`
|
? "Your email has been verified."
|
||||||
: "Enter the 6-digit code sent to your email"
|
: "Enter your email, then the code we send you"
|
||||||
}
|
}
|
||||||
footer={
|
footer={
|
||||||
<p>
|
<p>
|
||||||
@@ -96,43 +49,18 @@ function VerifyEmailForm() {
|
|||||||
</p>
|
</p>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-4">
|
||||||
{error && (
|
<KratosMessages flow={flow} error={error} />
|
||||||
<div className="rounded-md bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
|
||||||
{error}
|
{loading && !flow && (
|
||||||
|
<div className="flex items-center justify-center py-6 text-muted-foreground">
|
||||||
|
<Loader2 className="animate-spin" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<CodeInput
|
{flow && !verified && (
|
||||||
value={code}
|
<KratosFlowForm flow={flow} onResult={handleResult} />
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
</AuthFormWrapper>
|
</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
|
// Catch-all proxy route handler
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Every authenticated client-side API call goes through this proxy.
|
// Every authenticated client-side API call goes through this proxy.
|
||||||
// It reads the `honeydue-token` httpOnly cookie and forwards the request to the
|
// Identity is owned by Ory Kratos: the browser holds an `ory_kratos_session`
|
||||||
// Go API with an Authorization header.
|
// 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 =
|
const API_BASE_URL =
|
||||||
@@ -14,6 +15,8 @@ const API_BASE_URL =
|
|||||||
process.env.NEXT_PUBLIC_API_URL ||
|
process.env.NEXT_PUBLIC_API_URL ||
|
||||||
'https://honeyDue.treytartt.com/api';
|
'https://honeyDue.treytartt.com/api';
|
||||||
|
|
||||||
|
const KRATOS_SESSION_COOKIE = 'ory_kratos_session';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build the target URL from the catch-all path segments.
|
* Build the target URL from the catch-all path segments.
|
||||||
* e.g. /api/proxy/tasks/123/ -> https://honeyDue.treytartt.com/api/tasks/123/
|
* 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.
|
* 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> {
|
async function buildHeaders(request: NextRequest): Promise<Headers> {
|
||||||
const headers = new 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 cookieStore = await cookies();
|
||||||
const token = cookieStore.get('honeydue-token')?.value;
|
const session = cookieStore.get(KRATOS_SESSION_COOKIE)?.value;
|
||||||
if (token) {
|
if (session) {
|
||||||
headers.set('Authorization', `Token ${token}`);
|
headers.set('Cookie', `${KRATOS_SESSION_COOKIE}=${session}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return headers;
|
return headers;
|
||||||
|
|||||||
@@ -2,11 +2,13 @@
|
|||||||
|
|
||||||
import { TopBar } from '@/components/layout/top-bar';
|
import { TopBar } from '@/components/layout/top-bar';
|
||||||
import { MobileNav } from '@/components/layout/mobile-nav';
|
import { MobileNav } from '@/components/layout/mobile-nav';
|
||||||
|
import { AuthGate } from '@/components/auth/auth-gate';
|
||||||
import { DataProviderProvider } from '@/lib/demo/data-provider-context';
|
import { DataProviderProvider } from '@/lib/demo/data-provider-context';
|
||||||
import { realProvider } from '@/lib/demo/real-provider';
|
import { realProvider } from '@/lib/demo/real-provider';
|
||||||
|
|
||||||
export default function AppLayout({ children }: { children: React.ReactNode }) {
|
export default function AppLayout({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
|
<AuthGate>
|
||||||
<DataProviderProvider value={realProvider}>
|
<DataProviderProvider value={realProvider}>
|
||||||
<div className="min-h-screen bg-background">
|
<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]" />
|
<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 />
|
<MobileNav />
|
||||||
</div>
|
</div>
|
||||||
</DataProviderProvider>
|
</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 pathname = usePathname();
|
||||||
const { basePath } = useDataProvider();
|
const { basePath } = useDataProvider();
|
||||||
const user = useAuthStore((s) => s.user);
|
const user = useAuthStore((s) => s.user);
|
||||||
|
const logout = useAuthStore((s) => s.logout);
|
||||||
|
|
||||||
const navItems = getNavItems(basePath).filter((item) => item.label !== 'Settings');
|
const navItems = getNavItems(basePath).filter((item) => item.label !== 'Settings');
|
||||||
const initials = user
|
const initials = user
|
||||||
@@ -30,16 +31,14 @@ export function TopBar() {
|
|||||||
: 'U';
|
: 'U';
|
||||||
|
|
||||||
const handleLogout = async () => {
|
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')) {
|
if (basePath.startsWith('/demo')) {
|
||||||
|
// Demo mode has no real session — just leave demo.
|
||||||
router.push('/demo');
|
router.push('/demo');
|
||||||
} else {
|
return;
|
||||||
router.push('/login');
|
|
||||||
}
|
}
|
||||||
|
// Real mode: drive the Kratos browser logout flow. This clears the
|
||||||
|
// `ory_kratos_session` cookie and navigates the browser itself.
|
||||||
|
await logout();
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,117 +1,116 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
// ---------------------------------------------------------------------------
|
||||||
import { useForm } from "react-hook-form";
|
// ChangePasswordForm — password changes are owned by Ory Kratos
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
// ---------------------------------------------------------------------------
|
||||||
import { z } from "zod";
|
// The honeyDue Go API no longer handles passwords. Changing a password is a
|
||||||
import { toast } from "sonner";
|
// Kratos `settings` browser self-service flow. This card renders the password
|
||||||
import { Loader2, Check } from "lucide-react";
|
// 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 { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||||
import { FormField } from "@/components/shared/form-field";
|
import { KratosFlowForm } from "@/components/auth/kratos-flow-form";
|
||||||
import { PasswordInput } from "@/components/forms/password-input";
|
import { KratosMessages } from "@/components/auth/kratos-messages";
|
||||||
import * as authApi from "@/lib/api/auth";
|
import {
|
||||||
|
browserFlowUrl,
|
||||||
const changePasswordSchema = z
|
getFlow,
|
||||||
.object({
|
type KratosFlow,
|
||||||
current_password: z.string().min(8, "Password must be at least 8 characters"),
|
type KratosUiNode,
|
||||||
new_password: z.string().min(8, "Password must be at least 8 characters"),
|
} from "@/lib/kratos";
|
||||||
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>;
|
|
||||||
|
|
||||||
export function ChangePasswordForm() {
|
export function ChangePasswordForm() {
|
||||||
const [success, setSuccess] = useState(false);
|
const [flow, setFlow] = useState<KratosFlow | null>(null);
|
||||||
const [apiError, setApiError] = useState<string | null>(null);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const {
|
// Initialize a Kratos settings flow on mount. We fetch it directly (the user
|
||||||
register,
|
// is already logged in, so the settings flow can be created via the browser
|
||||||
handleSubmit,
|
// endpoint, which responds with the flow JSON for a live session).
|
||||||
reset,
|
useEffect(() => {
|
||||||
formState: { errors, isSubmitting },
|
let cancelled = false;
|
||||||
} = useForm<ChangePasswordFormData>({
|
|
||||||
resolver: zodResolver(changePasswordSchema),
|
// 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) {
|
return () => {
|
||||||
setSuccess(false);
|
cancelled = true;
|
||||||
setApiError(null);
|
};
|
||||||
try {
|
}, []);
|
||||||
await authApi.changePassword({
|
|
||||||
current_password: data.current_password,
|
function handleResult(result: { status: number; ok: boolean; data: unknown }) {
|
||||||
new_password: data.new_password,
|
if (result.data && typeof result.data === "object" && "ui" in result.data) {
|
||||||
});
|
setFlow(result.data as KratosFlow);
|
||||||
reset();
|
} else if (result.ok && flow) {
|
||||||
setSuccess(true);
|
// Re-fetch the flow so the form (and any success message) is fresh.
|
||||||
toast.success("Password changed");
|
getFlow("settings", flow.id)
|
||||||
} catch (err) {
|
.then(setFlow)
|
||||||
const message =
|
.catch(() => {});
|
||||||
err instanceof Error ? err.message : "Failed to change password.";
|
|
||||||
setApiError(message);
|
|
||||||
toast.error("Failed to change password");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Change Password</CardTitle>
|
<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>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
<div className="flex flex-col gap-4">
|
||||||
{apiError && (
|
<KratosMessages flow={flow} error={error} />
|
||||||
<div className="rounded-md bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
|
||||||
{apiError}
|
{loading && !flow && (
|
||||||
</div>
|
<div className="flex items-center justify-center py-4 text-muted-foreground">
|
||||||
)}
|
<Loader2 className="animate-spin" />
|
||||||
{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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<FormField label="Current password" htmlFor="current_password" error={errors.current_password?.message} required>
|
{passwordFlow && (
|
||||||
<PasswordInput
|
<KratosFlowForm
|
||||||
id="current_password"
|
flow={passwordFlow}
|
||||||
autoComplete="current-password"
|
onResult={handleResult}
|
||||||
aria-invalid={!!errors.current_password}
|
submitLabel="Update Password"
|
||||||
{...register("current_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>
|
</div>
|
||||||
</form>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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
|
// Identity (login, registration, recovery, verification, password changes,
|
||||||
// httpOnly cookie. All other auth routes use the catch-all proxy.
|
// 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)
|
// Response shapes
|
||||||
// TODO: import from @/lib/types once the shared types package is finalised
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export interface LoginRequest {
|
/**
|
||||||
username?: string;
|
* The current user as returned by the honeyDue Go API `GET /auth/me`.
|
||||||
email?: string;
|
* The canonical identity (email, verification status) is owned by Kratos;
|
||||||
password: string;
|
* this is the honeyDue-side projection used to render the app UI.
|
||||||
}
|
*/
|
||||||
|
|
||||||
/** 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UserResponse {
|
export interface UserResponse {
|
||||||
id: number;
|
id: number;
|
||||||
username: string;
|
username: string;
|
||||||
@@ -53,245 +39,50 @@ export interface UpdateProfileRequest {
|
|||||||
last_name?: string;
|
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;
|
* Update the authenticated user's honeyDue-side profile.
|
||||||
code: string;
|
* Note: changing the email/password of the *identity* itself is done through
|
||||||
}
|
* the Kratos `settings` flow, not here.
|
||||||
|
*/
|
||||||
export interface VerifyResetCodeResponse {
|
export function updateProfile(
|
||||||
message: string;
|
data: UpdateProfileRequest,
|
||||||
reset_token: string;
|
): Promise<UserResponse> {
|
||||||
}
|
return apiFetch<UserResponse>('/auth/profile/', {
|
||||||
|
method: 'PUT',
|
||||||
export interface ResetPasswordRequest {
|
body: JSON.stringify(data),
|
||||||
reset_token: string;
|
});
|
||||||
new_password: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface VerifyEmailRequest {
|
|
||||||
code: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface VerifyEmailResponse {
|
|
||||||
message: string;
|
|
||||||
verified: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MessageResponse {
|
export interface MessageResponse {
|
||||||
message: string;
|
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.
|
* Delete the authenticated user's honeyDue account data.
|
||||||
* Uses the dedicated `/api/auth/login` route handler which sets the httpOnly
|
*
|
||||||
* cookie and strips the token from the response.
|
* 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> {
|
export function deleteAccount(): Promise<MessageResponse> {
|
||||||
const res = await fetch('/api/auth/login', {
|
return apiFetch<MessageResponse>('/auth/account/', {
|
||||||
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', {
|
|
||||||
method: 'DELETE',
|
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
|
// Base API client for honeyDue web app
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// All client-side requests go through Next.js API route handlers (proxy).
|
// Identity is owned by Ory Kratos. Authenticated requests to the honeyDue Go
|
||||||
// The proxy reads the httpOnly `honeydue-token` cookie and forwards it to the
|
// API carry the Kratos session via the `ory_kratos_session` cookie.
|
||||||
// Go API as an Authorization header. This avoids exposing the token to JS.
|
//
|
||||||
|
// 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 =
|
const API_BASE_URL =
|
||||||
@@ -15,6 +18,9 @@ const API_BASE_URL =
|
|||||||
*/
|
*/
|
||||||
const SERVER_API_URL = process.env.API_URL || 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
|
// Error class
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -36,7 +42,7 @@ export class ApiError extends Error {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Client-side authenticated fetch. Calls the Next.js catch-all proxy which
|
* 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 path API path *without* the `/api` prefix, e.g. `/tasks/`
|
||||||
* @param options Standard RequestInit overrides
|
* @param options Standard RequestInit overrides
|
||||||
@@ -64,7 +70,8 @@ export async function apiFetch<T>(
|
|||||||
delete headers['Content-Type'];
|
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) {
|
if (!res.ok) {
|
||||||
const body = await res.json().catch(() => ({ error: 'Unknown error' }));
|
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
|
* Server-side fetch that reads the Kratos session cookie and calls the Go API
|
||||||
* and calls the Go API directly (no proxy hop).
|
* 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:
|
* Only use this inside:
|
||||||
* - `app/api/.../route.ts` handlers
|
* - `app/api/.../route.ts` handlers
|
||||||
@@ -102,7 +110,7 @@ export async function serverFetch<T>(
|
|||||||
// (the function itself should only be *called* on the server).
|
// (the function itself should only be *called* on the server).
|
||||||
const { cookies } = await import('next/headers');
|
const { cookies } = await import('next/headers');
|
||||||
const cookieStore = await cookies();
|
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 normalized = path.endsWith('/') ? path : `${path}/`;
|
||||||
const url = `${SERVER_API_URL}${normalized}`;
|
const url = `${SERVER_API_URL}${normalized}`;
|
||||||
@@ -113,8 +121,9 @@ export async function serverFetch<T>(
|
|||||||
...(options.headers as Record<string, string>),
|
...(options.headers as Record<string, string>),
|
||||||
};
|
};
|
||||||
|
|
||||||
if (token) {
|
// Forward the Kratos session cookie to the Go API.
|
||||||
headers['Authorization'] = `Token ${token}`;
|
if (session) {
|
||||||
|
headers['Cookie'] = `${KRATOS_SESSION_COOKIE}=${session}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (options.body instanceof FormData) {
|
if (options.body instanceof FormData) {
|
||||||
|
|||||||
@@ -153,7 +153,11 @@ export interface DataProvider {
|
|||||||
|
|
||||||
auth: {
|
auth: {
|
||||||
getCurrentUser(): Promise<UserResponse>;
|
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: {
|
auth: {
|
||||||
getCurrentUser: async () => demoUser,
|
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 notificationsApi from '@/lib/api/notifications';
|
||||||
import * as subscriptionApi from '@/lib/api/subscription';
|
import * as subscriptionApi from '@/lib/api/subscription';
|
||||||
import * as authApi from '@/lib/api/auth';
|
import * as authApi from '@/lib/api/auth';
|
||||||
|
import { logout as kratosLogout } from '@/lib/kratos';
|
||||||
|
|
||||||
export const realProvider: DataProvider = {
|
export const realProvider: DataProvider = {
|
||||||
basePath: '/app',
|
basePath: '/app',
|
||||||
@@ -94,6 +95,7 @@ export const realProvider: DataProvider = {
|
|||||||
|
|
||||||
auth: {
|
auth: {
|
||||||
getCurrentUser: () => authApi.getCurrentUser(),
|
getCurrentUser: () => authApi.getCurrentUser(),
|
||||||
logout: () => authApi.logout(),
|
// Hands the browser off to the Kratos logout flow.
|
||||||
|
logout: () => kratosLogout(),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,8 +1,16 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useDataProvider, useQueryKeyPrefix } from '@/lib/demo/data-provider-context';
|
|
||||||
import { useRouter } from 'next/navigation';
|
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() {
|
export function useCurrentUser() {
|
||||||
const { auth } = useDataProvider();
|
const { auth } = useDataProvider();
|
||||||
@@ -24,11 +32,11 @@ export function useLogout() {
|
|||||||
mutationFn: () => auth.logout(),
|
mutationFn: () => auth.logout(),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.clear();
|
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')) {
|
if (basePath.startsWith('/demo')) {
|
||||||
router.push('/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
|
// Auth / profile types
|
||||||
// Generated from:
|
// ============================================================================
|
||||||
// honeyDueAPI-go/internal/dto/requests/auth.go
|
// Identity (login, registration, recovery, verification, password changes,
|
||||||
// honeyDueAPI-go/internal/dto/responses/auth.go
|
// 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
|
// 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 {
|
export interface UpdateProfileRequest {
|
||||||
email?: string | null;
|
email?: string | null;
|
||||||
first_name?: string | null;
|
first_name?: string | null;
|
||||||
last_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
|
// Responses
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -89,17 +45,6 @@ export interface UserProfileResponse {
|
|||||||
profile_picture: string;
|
profile_picture: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LoginResponse {
|
|
||||||
token: string;
|
|
||||||
user: UserResponse;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RegisterResponse {
|
|
||||||
token: string;
|
|
||||||
user: UserResponse;
|
|
||||||
message: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CurrentUserResponse {
|
export interface CurrentUserResponse {
|
||||||
id: number;
|
id: number;
|
||||||
username: string;
|
username: string;
|
||||||
@@ -112,36 +57,6 @@ export interface CurrentUserResponse {
|
|||||||
profile?: UserProfileResponse | null;
|
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)
|
// User summary types (from responses/user.go)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
+3
-18
@@ -6,29 +6,14 @@
|
|||||||
export { ApiError } from "./api";
|
export { ApiError } from "./api";
|
||||||
export type { ErrorResponse, MessageResponse, PaginatedResponse } 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 {
|
export type {
|
||||||
LoginRequest,
|
|
||||||
RegisterRequest,
|
|
||||||
VerifyEmailRequest,
|
|
||||||
ForgotPasswordRequest,
|
|
||||||
VerifyResetCodeRequest,
|
|
||||||
ResetPasswordRequest,
|
|
||||||
UpdateProfileRequest,
|
UpdateProfileRequest,
|
||||||
ResendVerificationRequest,
|
|
||||||
AppleSignInRequest,
|
|
||||||
GoogleSignInRequest,
|
|
||||||
UserResponse,
|
UserResponse,
|
||||||
UserProfileResponse,
|
UserProfileResponse,
|
||||||
LoginResponse,
|
|
||||||
RegisterResponse,
|
|
||||||
CurrentUserResponse,
|
CurrentUserResponse,
|
||||||
VerifyEmailResponse,
|
|
||||||
ForgotPasswordResponse,
|
|
||||||
VerifyResetCodeResponse,
|
|
||||||
ResetPasswordResponse,
|
|
||||||
AppleSignInResponse,
|
|
||||||
GoogleSignInResponse,
|
|
||||||
UserSummary,
|
UserSummary,
|
||||||
UserProfileSummary,
|
UserProfileSummary,
|
||||||
} from "./auth";
|
} 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 { NextResponse } from 'next/server';
|
||||||
import type { NextRequest } 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) {
|
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;
|
const { pathname } = request.nextUrl;
|
||||||
|
|
||||||
// Public paths that don't require auth
|
// Public paths that don't require auth.
|
||||||
const publicPaths = ['/', '/login', '/register', '/forgot-password', '/reset-password', '/verify-email', '/demo', '/help'];
|
const publicPaths = [
|
||||||
const isPublicPath = publicPaths.some(p => pathname === p || pathname.startsWith(p + '/'));
|
'/',
|
||||||
|
'/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 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();
|
if (isApiPath || isStaticPath) return NextResponse.next();
|
||||||
|
|
||||||
// No token + protected path → redirect to login
|
// No session cookie + protected path -> redirect to login.
|
||||||
if (!token && !isPublicPath) {
|
if (!hasSession && !isPublicPath) {
|
||||||
return NextResponse.redirect(new URL('/login', request.url));
|
return NextResponse.redirect(new URL('/login', request.url));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Has token + auth page → redirect to app
|
// Has a session cookie + on the login/register pages -> send to the app.
|
||||||
if (token && (pathname === '/login' || pathname === '/register')) {
|
// (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));
|
return NextResponse.redirect(new URL('/app', request.url));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+54
-59
@@ -1,25 +1,38 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import * as authApi from '@/lib/api/auth';
|
import * as authApi from '@/lib/api/auth';
|
||||||
import { getQueryClient } from '@/lib/query/query-client';
|
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';
|
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 {
|
interface AuthState {
|
||||||
|
/** honeyDue-side user profile (GET /auth/me), null if not loaded / signed out. */
|
||||||
user: UserResponse | null;
|
user: UserResponse | null;
|
||||||
|
/** True once a live Kratos session has been confirmed. */
|
||||||
isAuthenticated: boolean;
|
isAuthenticated: boolean;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
|
|
||||||
login: (credentials: { username: string; password: string }) => Promise<void>;
|
/**
|
||||||
register: (data: {
|
* Confirm the Kratos session and, if valid, load the honeyDue profile.
|
||||||
username: string;
|
* Call this on app startup to hydrate the store.
|
||||||
email: string;
|
*/
|
||||||
password: string;
|
hydrate: () => Promise<void>;
|
||||||
first_name: string;
|
/** Reload just the honeyDue profile (after a profile update). */
|
||||||
last_name: string;
|
|
||||||
}) => Promise<void>;
|
|
||||||
logout: () => Promise<void>;
|
|
||||||
fetchUser: () => Promise<void>;
|
fetchUser: () => Promise<void>;
|
||||||
|
/** Drive the Kratos browser logout flow and clear local state. */
|
||||||
|
logout: () => Promise<void>;
|
||||||
clearError: () => void;
|
clearError: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -29,65 +42,47 @@ export const useAuthStore = create<AuthState>()((set) => ({
|
|||||||
isLoading: false,
|
isLoading: false,
|
||||||
error: null,
|
error: null,
|
||||||
|
|
||||||
login: async (credentials) => {
|
hydrate: async () => {
|
||||||
set({ isLoading: true, error: null });
|
set({ isLoading: true, error: null });
|
||||||
try {
|
try {
|
||||||
const response = await authApi.login(credentials);
|
const session = await whoami();
|
||||||
trackEvent(AnalyticsEvents.USER_SIGNED_IN, { method: 'email', platform: 'web' });
|
if (!session || !session.active) {
|
||||||
set({ user: response.user, isAuthenticated: true, isLoading: false });
|
set({ user: null, isAuthenticated: false, isLoading: false });
|
||||||
window.location.href = '/app';
|
return;
|
||||||
} catch (err) {
|
|
||||||
const message =
|
|
||||||
err instanceof Error ? err.message : 'Login failed. Please try again.';
|
|
||||||
set({ isLoading: false, error: message });
|
|
||||||
}
|
}
|
||||||
},
|
// Session is valid — load the honeyDue-side profile.
|
||||||
|
|
||||||
register: async (data) => {
|
|
||||||
set({ isLoading: true, error: null });
|
|
||||||
try {
|
|
||||||
await authApi.register(data);
|
|
||||||
trackEvent(AnalyticsEvents.USER_REGISTERED, { method: 'email', platform: 'web' });
|
|
||||||
set({ isLoading: false });
|
|
||||||
} catch (err) {
|
|
||||||
const message =
|
|
||||||
err instanceof Error
|
|
||||||
? err.message
|
|
||||||
: 'Registration failed. Please try again.';
|
|
||||||
set({ isLoading: false, error: message });
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
logout: async () => {
|
|
||||||
set({ isLoading: true, error: null });
|
|
||||||
try {
|
|
||||||
await authApi.logout();
|
|
||||||
} catch {
|
|
||||||
// Even if logout fails server-side, clear local state
|
|
||||||
} finally {
|
|
||||||
// Clear React Query cache to prevent stale data leaking into demo mode
|
|
||||||
getQueryClient().clear();
|
|
||||||
resetAnalytics();
|
|
||||||
set({
|
|
||||||
user: null,
|
|
||||||
isAuthenticated: false,
|
|
||||||
isLoading: false,
|
|
||||||
error: null,
|
|
||||||
});
|
|
||||||
window.location.href = '/login';
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
fetchUser: async () => {
|
|
||||||
set({ isLoading: true, error: null });
|
|
||||||
try {
|
try {
|
||||||
const user = await authApi.getCurrentUser();
|
const user = await authApi.getCurrentUser();
|
||||||
set({ user, isAuthenticated: true, isLoading: false });
|
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 {
|
} catch {
|
||||||
set({ user: null, isAuthenticated: false, isLoading: false });
|
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 }),
|
clearError: () => set({ error: null }),
|
||||||
}));
|
}));
|
||||||
|
|||||||
Reference in New Issue
Block a user