'password']) || empty($body['name'])) {
return Response::json(['error' => 'Missing required fields'], 400);
}
// Check for duplicate email
$existing = $this->auth->findByEmail($body['email']);
if ($existing !== null) {
return Response::json([
'error' => 'Validation failed',
'details' => ['email' => 'Email already registered'],
], 422);
}
$result = $this->auth->register($body);
return Response::json([
'data' => [
'message' => 'Registration successful',
'user' => [
'id' => $result['user']->id,
'email' => $result['user']->email,
'full_name' => $result['user']->full_name,
],
'access_token' => $result['access_token'],
'refresh_token' => $result['refresh_token'],
'token_type' => 'Bearer',
'expires_in' => $result['expires_in'],
],
], 201);
}
#[Route('GET', '/me', name: 'auth.me')]
#[Authenticated]
public function me(ServerRequestInterface $request): Response
{
// NOTE: The attribute is 'auth.user', NOT 'user'
$user = $request->getAttribute('auth.user');
return Response::json([
'data' => [
'id' => $user->id,
'email' => $user->email,
'full_name' => $user->full_name,
'status' => $user->status,
'created_at' => $user->created_at->format('c'),
],
]);
}
}
Enter fullscreen mode Exit fullscreen mode
> **Tip:** Always use `$request->getAttribute('auth.user')` β the `AuthenticationMiddleware` sets the attribute as `auth.user`, not `user`.
* * *
## [](#frontend-api-client)Frontend: API Client
Create a reusable API client that handles JWT tokens, automatic refresh, and typed requests.
### [](#-raw-srclibapits-endraw-)`src/lib/api.ts`
"use client";
const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8088";
interface ApiOptions {
method?: string;
body?: unknown;
headers?: Record<string, string>;
token?: string | null;
}
class ApiClient {
private baseUrl: string;
constructor(baseUrl: string) {
this.baseUrl = baseUrl;
}
private getToken(): string | null {
if (typeof window === "undefined") return null;
return localStorage.getItem("mm_token");
}
async request<T = unknown>(path: string, options: ApiOptions = {}): Promise<T> {
const { method = "GET", body, headers = {}, token } = options;
const authToken = token ?? this.getToken();
const res = await fetch(`${this.baseUrl}${path}`, {
method,
headers: {
"Content-Type": "application/json",
...(authToken ? { Authorization: `Bearer ${authToken}` } : {}),
...headers,
},
body: body ? JSON.stringify(body) : undefined,
});
// Auto-refresh on 401
if (res.status === 401) {
const refreshed = await this.refreshToken();
if (refreshed) {
return this.request<T>(path, { ...options, token: this.getToken() });
}
this.clearTokens();
throw new Error("Unauthorized");
}
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }));
throw new Error(err.error || err.message || "Request failed");
}
return res.json();
}
private async refreshToken(): Promise<boolean> {
const refresh = typeof window !== "undefined"
? localStorage.getItem("mm_refresh")
: null;
if (!refresh) return false;
try {
const res = await fetch(`${this.baseUrl}/api/v2/auth/refresh`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ refresh_token: refresh }),
});
if (!res.ok) return false;
const data = await res.json();
if (data.data?.access_token) {
localStorage.setItem("mm_token", data.data.access_token);
if (data.data.refresh_token) {
localStorage.setItem("mm_refresh", data.data.refresh_token);
}
return true;
}
return false;
} catch {
return false;
}
}
private clearTokens() {
if (typeof window !== "undefined") {
localStorage.removeItem("mm_token");
localStorage.removeItem("mm_refresh");
window.location.href = "/login";
}
}
// ββ Auth Methods ββββββββββββββββββββββββββββββββββββββββββ
login(email: string, password: string) {
return this.request<{
data: {
access_token: string;
refresh_token: string;
user: { id: number; email: string; full_name: string };
};
}>("/api/v2/auth/login", { method: "POST", body: { email, password } });
}
register(name: string, email: string, password: string, companyName: string) {
return this.request<{
data: {
access_token: string;
refresh_token: string;
user: { id: number; email: string; full_name: string };
};
}>("/api/v2/auth/register", {
method: "POST",
body: { name, email, password, company_name: companyName },
});
}
}
export const api = new ApiClient(API_URL);
Enter fullscreen mode Exit fullscreen mode
* * *
## [](#frontend-auth-context-provider)Frontend: Auth Context Provider
Wrap your app with `AuthProvider` to share auth state across all components.
### [](#-raw-srclibauthtsx-endraw-)`src/lib/auth.tsx`
"use client";
import React, {
createContext, useContext, useEffect, useState, useCallback,
type ReactNode,
} from "react";
import { api } from "./api";
interface User {
id: number;
name: string;
email: string;
}
interface AuthState {
user: User | null;
loading: boolean;
login: (email: string, password: string) => Promise<void>;
register: (
name: string, email: string, password: string, companyName: string
) => Promise<void>;
logout: () => void;
}
const AuthContext = createContext<AuthState | null>(null);
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
// On mount: validate stored token by calling /me
useEffect(() => {
const token = localStorage.getItem("mm_token");
if (token) {
api
.request<{ data: { id: number; email: string; full_name: string } }>(
"/api/v2/auth/me"
)
.then((res) =>
setUser({
id: res.data.id,
email: res.data.email,
name: res.data.full_name,
})
)
.catch(() => {
localStorage.removeItem("mm_token");
localStorage.removeItem("mm_refresh");
})
.finally(() => setLoading(false));
} else {
setLoading(false);
}
}, []);
const login = useCallback(async (email: string, password: string) => {
const res = await api.login(email, password);
localStorage.setItem("mm_token", res.data.access_token);
localStorage.setItem("mm_refresh", res.data.refresh_token);
// Map full_name β name for frontend consistency
const u = res.data.user;
setUser({ id: u.id, email: u.email, name: u.full_name });
}, []);
const register = useCallback(
async (
name: string, email: string, password: string, companyName: string
) => {
const res = await api.register(name, email, password, companyName);
localStorage.setItem("mm_token", res.data.access_token);
localStorage.setItem("mm_refresh", res.data.refresh_token);
const u = res.data.user;
setUser({ id: u.id, email: u.email, name: u.full_name });
},
[]
);
const logout = useCallback(() => {
localStorage.removeItem("mm_token");
localStorage.removeItem("mm_refresh");
setUser(null);
window.location.href = "/login";
}, []);
return (
<AuthContext.Provider value={{ user, loading, login, register, logout }}>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error("useAuth must be used within AuthProvider");
return ctx;
}
Enter fullscreen mode Exit fullscreen mode
> **Note:** The API returns `full_name` but we map it to `name` in the frontend `User` interface for simplicity. This mapping happens in three places: `login`, `register`, and the `/me` response handler.
### [](#wire-into-layout-raw-srcapplayouttsx-endraw-)Wire Into Layout β `src/app/layout.tsx`
import { AuthProvider } from "@/lib/auth";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<AuthProvider>{children}</AuthProvider>
</body>
</html>
);
}
Enter fullscreen mode Exit fullscreen mode
* * *
## [](#frontend-register-page)Frontend: Register Page
### [](#-raw-srcappauthregisterpagetsx-endraw-)`src/app/(auth)/register/page.tsx`
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { useAuth } from "@/lib/auth";
export default function RegisterPage() {
const { register } = useAuth();
const router = useRouter();
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [companyName, setCompanyName] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError("");
setLoading(true);
try {
await register(name, email, password, companyName);
router.push("/login"); // Redirect to login after registration
} catch (err: unknown) {
setError(err instanceof Error ? err.message : "Registration failed");
} finally {
setLoading(false);
}
}
return (
<form onSubmit={handleSubmit}>
<h1>Create Account</h1>
{error && <div className="error">{error}</div>}
<input type="text" value={name} onChange={(e) => setName(e.target.value)} placeholder="Full name" required />
<input type="email" value={email} onChange={(e) => setEmail(e.target.value)} placeholder="Email" required />
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} placeholder="Password" required />
<input type="text" value={companyName} onChange={(e) => setCompanyName(e.target.value)} placeholder="Company name" required />
<button type="submit" disabled={loading}>
{loading ? "Creating..." : "Create Account"}
</button>
<p>Already have an account? <Link href="/login">Sign in</Link></p>
</form>
);
}
Enter fullscreen mode Exit fullscreen mode
* * *
## [](#frontend-login-page)Frontend: Login Page
### [](#-raw-srcappauthloginpagetsx-endraw-)`src/app/(auth)/login/page.tsx`
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { useAuth } from "@/lib/auth";
export default function LoginPage() {
const { login } = useAuth();
const router = useRouter();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError("");
setLoading(true);
try {
await login(email, password);
router.push("/"); // Redirect to dashboard
} catch (err: unknown) {
setError(err instanceof Error ? err.message : "Login failed");
} finally {
setLoading(false);
}
}
return (
<form onSubmit={handleSubmit}>
<h1>Welcome Back</h1>
{error && <div className="error">{error}</div>}
<input type="email" value={email} onChange={(e) => setEmail(e.target.value)} placeholder="Email" required />
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} placeholder="Password" required />
<button type="submit" disabled={loading}>
{loading ? "Signing in..." : "Sign in"}
</button>
<p>Don't have an account? <Link href="/register">Create one</Link></p>
</form>
);
}
Enter fullscreen mode Exit fullscreen mode
* * *
## [](#frontend-protected-routes)Frontend: Protected Routes
Wrap dashboard pages with a layout that redirects unauthenticated users.
### [](#-raw-srcappdashboardlayouttsx-endraw-)`src/app/(dashboard)/layout.tsx`
"use client";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
import { useAuth } from "@/lib/auth";
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
const { user, loading } = useAuth();
const router = useRouter();
useEffect(() => {
if (!loading && !user) {
router.push("/login");
}
}, [user, loading, router]);
if (loading) {
return <div>Loading...</div>;
}
if (!user) return null;
return <>{children}</>;
}
Enter fullscreen mode Exit fullscreen mode
### [](#using-the-user-in-components)Using the User in Components
"use client";
import { useAuth } from "@/lib/auth";
export default function ProfileCard() {
const { user, logout } = useAuth();
return (
<div>
<h2>Welcome, {user?.name}</h2>
<p>{user?.email}</p>
<button onClick={logout}>Sign Out</button>
</div>
);
}
Enter fullscreen mode Exit fullscreen mode
* * *
## [](#token-refresh-flow)Token Refresh Flow
The API client handles token refresh **automatically**. Here's the sequence:
1. Browser makes a request (e.g. `GET /api/v2/messages`)
2. API Client attaches the stored `Bearer` token
3. If the API returns `401 Unauthorized` (token expired):
- Client sends `POST /api/v2/auth/refresh` with the refresh token
- On success: stores the new tokens and **retries the original request**
- On failure: clears all tokens and redirects to `/login`
### [](#token-lifetimes)Token Lifetimes
Token
Lifetime
Storage Key
Access Token
30 minutes
`localStorage("mm_token")`
Refresh Token
7 days
`localStorage("mm_refresh")`
> **Warning:** If both tokens are expired (user inactive for 7+ days), the client clears storage and redirects to `/login`.
* * *
## [](#common-issues-amp-solutions)Common Issues & Solutions
### [](#1-raw-typeerror-cannot-assign-string-to-property-of-type-datetimeimmutable-endraw-)1\. `TypeError: Cannot assign string to property ... of type DateTimeImmutable`
**Cause:** `DatabaseUserProvider` in MonkeysLegion β€ 2.0.7 does raw assignment from PDO strings to typed properties.
**Fix:** Upgrade to `monkeyscloud/monkeyslegion:^2.0.8` which includes `castValue()` type coercion in the hydrator.
* * *
### [](#2-raw-me-endraw-returns-raw-null-endraw-user-raw-requestgtgetattributeuser-endraw-is-null)2\. `/me` returns `null` user β `$request->getAttribute('user')` is null
**Cause:** The `AuthenticationMiddleware` sets the attribute as `auth.user`, not `user`.
**Fix:**
- $user = $request->getAttribute('user');
- $user = $request->getAttribute('auth.user');
Enter fullscreen mode Exit fullscreen mode
* * *
### [](#3-raw-422-unprocessable-content-endraw-on-register)3\. `422 Unprocessable Content` on register
**Cause:** The email is already registered. The API returns:
{ "error": "Validation failed", "details": { "email": "Email already registered" } }
Enter fullscreen mode Exit fullscreen mode
**Fix:** Use a different email, or parse the error details in the frontend to show a specific message.
* * *
### [](#4-raw-fullname-endraw-vs-raw-name-endraw-mismatch)4\. `full_name` vs `name` mismatch
**Cause:** The API returns `full_name` but your frontend `User` interface expects `name`.
**Fix:** Map the field in every place you read user data:
setUser({ id: u.id, email: u.email, name: u.full_name });
Enter fullscreen mode Exit fullscreen mode
* * *
### [](#5-cors-errors-from-raw-localhost3000-endraw-%E2%86%92-raw-localhost8088-endraw-)5\. CORS errors from `localhost:3000` β `localhost:8088`
**Cause:** The API needs CORS headers for cross-origin requests.
**Fix:** Add CORS middleware in your MonkeysLegion middleware stack:
// config/middleware.mlc
MonkeysLegion\Http\Middleware\CorsMiddleware::class
Enter fullscreen mode Exit fullscreen mode
Or set the environment variable:
CORS_ORIGINS=http://localhost:3000
Enter fullscreen mode Exit fullscreen mode
* * *
### [](#6-page-redirects-to-raw-login-endraw-on-refresh-despite-being-logged-in)6\. Page redirects to `/login` on refresh despite being logged in
**Cause:** The `/me` endpoint crashes (often the `DateTimeImmutable` bug), so the auth provider clears tokens and sets `user = null`.
**Fix:** Upgrade MonkeysLegion to `2.0.8+` and verify `/me` works:
curl -s http://localhost:8088/api/v2/auth/me
-H "Authorization: Bearer YOUR_TOKEN"
Enter fullscreen mode Exit fullscreen mode
* * *
## [](#quick-reference)Quick Reference
Register a user via curl
curl -X POST http://localhost:8088/api/v2/auth/register
-H "Content-Type: application/json"
-d '{
"name": "John Doe",
"email": "john@example.com",
"password": "securepass123",
"company_name": "Acme Inc."
}'
Login
curl -X POST http://localhost:8088/api/v2/auth/login
-H "Content-Type: application/json"
-d '{"email": "john@example.com", "password": "securepass123"}'
Get current user
curl http://localhost:8088/api/v2/auth/me
-H "Authorization: Bearer <access_token>"
Refresh token
curl -X POST http://localhost:8088/api/v2/auth/refresh
-H "Content-Type: application/json"
-d '{"refresh_token": "<refresh_token>"}'
Enter fullscreen mode Exit fullscreen mode
[MonkeysLegion](https://monkeyslegion.com/)