mirror change from https://github.com/binhkid2/FullStack-Blog-Nestjs-Nextjs-Postgres
This commit is contained in:
129
frontend/lib/api.ts
Normal file
129
frontend/lib/api.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* API helpers for server components (forwards cookies) and client components (credentials: include).
|
||||
*/
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:3001";
|
||||
|
||||
const STATE_CHANGING_METHODS = ["POST", "PATCH", "PUT", "DELETE"];
|
||||
|
||||
// ─── Read csrfToken cookie in the browser ─────────────────────────────────────
|
||||
function getClientCsrfToken(): string {
|
||||
if (typeof document === "undefined") return "";
|
||||
const match = document.cookie.match(/(?:^|;\s*)csrfToken=([^;]+)/);
|
||||
return match ? decodeURIComponent(match[1]) : "";
|
||||
}
|
||||
|
||||
// ─── Server-side fetch (use in Server Components / Route Handlers) ─────────────
|
||||
|
||||
export async function apiFetch<T = unknown>(
|
||||
path: string,
|
||||
init?: RequestInit
|
||||
): Promise<T> {
|
||||
// Dynamic import so this module can also be imported in client components
|
||||
// The `cookies` call only runs on the server side
|
||||
let cookieHeader = "";
|
||||
let csrfToken = "";
|
||||
try {
|
||||
const { cookies } = await import("next/headers");
|
||||
const store = await cookies();
|
||||
cookieHeader = store
|
||||
.getAll()
|
||||
.map((c) => `${c.name}=${c.value}`)
|
||||
.join("; ");
|
||||
// Forward the CSRF token from the cookie store for mutating server-side calls
|
||||
csrfToken = store.get("csrfToken")?.value ?? "";
|
||||
} catch {
|
||||
// We're on the client — skip cookie forwarding
|
||||
}
|
||||
|
||||
const method = (init?.method ?? "GET").toUpperCase();
|
||||
const needsCsrf = STATE_CHANGING_METHODS.includes(method);
|
||||
|
||||
const res = await fetch(`${API_URL}${path}`, {
|
||||
...init,
|
||||
cache: "no-store",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(cookieHeader ? { Cookie: cookieHeader } : {}),
|
||||
...(needsCsrf && csrfToken ? { "x-csrf-token": csrfToken } : {}),
|
||||
...(init?.headers as Record<string, string> | undefined),
|
||||
},
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
let msg = `API error ${res.status}`;
|
||||
try {
|
||||
const body = await res.json();
|
||||
msg = body?.error?.message || body?.message || msg;
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
throw new ApiError(msg, res.status);
|
||||
}
|
||||
|
||||
// Some endpoints return 204 No Content
|
||||
const text = await res.text();
|
||||
if (!text) return undefined as T;
|
||||
return JSON.parse(text) as T;
|
||||
}
|
||||
|
||||
// ─── Client-side fetch (use in Client Components) ─────────────────────────────
|
||||
|
||||
async function doClientFetch(path: string, init?: RequestInit): Promise<Response> {
|
||||
const method = (init?.method ?? "GET").toUpperCase();
|
||||
const needsCsrf = STATE_CHANGING_METHODS.includes(method);
|
||||
const csrfToken = needsCsrf ? getClientCsrfToken() : "";
|
||||
|
||||
return fetch(`${API_URL}${path}`, {
|
||||
...init,
|
||||
credentials: "include",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(needsCsrf && csrfToken ? { "x-csrf-token": csrfToken } : {}),
|
||||
...(init?.headers as Record<string, string> | undefined),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function clientFetch<T = unknown>(
|
||||
path: string,
|
||||
init?: RequestInit
|
||||
): Promise<T> {
|
||||
let res = await doClientFetch(path, init);
|
||||
|
||||
// Auto-refresh expired access token on 401, then retry once.
|
||||
// Skip for /auth/ paths to avoid refresh loops.
|
||||
if (res.status === 401 && !path.startsWith("/auth/")) {
|
||||
const refreshRes = await doClientFetch("/auth/refresh", { method: "POST" });
|
||||
if (refreshRes.ok) {
|
||||
res = await doClientFetch(path, init);
|
||||
}
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
let msg = `API error ${res.status}`;
|
||||
try {
|
||||
const body = await res.json();
|
||||
msg = body?.error?.message || body?.message || msg;
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
throw new ApiError(msg, res.status);
|
||||
}
|
||||
|
||||
const text = await res.text();
|
||||
if (!text) return undefined as T;
|
||||
return JSON.parse(text) as T;
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public status: number
|
||||
) {
|
||||
super(message);
|
||||
this.name = "ApiError";
|
||||
}
|
||||
}
|
||||
|
||||
export { API_URL };
|
||||
128
frontend/lib/auth.tsx
Normal file
128
frontend/lib/auth.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useState,
|
||||
} from "react";
|
||||
import { clientFetch, ApiError } from "./api";
|
||||
import { CurrentUser, UserRole } from "./types";
|
||||
|
||||
interface AuthContextValue {
|
||||
currentUser: CurrentUser | null;
|
||||
loading: boolean;
|
||||
login: (email: string, password: string) => Promise<void>;
|
||||
register: (name: string, email: string, password: string) => Promise<void>;
|
||||
logout: () => Promise<void>;
|
||||
refresh: () => Promise<void>;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextValue | null>(null);
|
||||
|
||||
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
const [currentUser, setCurrentUser] = useState<CurrentUser | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
/** Calls GET /auth/me with the httpOnly cookie to restore the current user.
|
||||
* Returns the JWT payload if valid, clears auth state if 401. */
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
const data = await clientFetch<{ success: boolean; user: CurrentUser }>(
|
||||
"/auth/me"
|
||||
);
|
||||
if (data.user) {
|
||||
const user: CurrentUser = {
|
||||
sub: data.user.sub,
|
||||
email: data.user.email,
|
||||
role: (data.user.role as string).toUpperCase() as UserRole,
|
||||
name: data.user.name,
|
||||
};
|
||||
setCurrentUser(user);
|
||||
sessionStorage.setItem("currentUser", JSON.stringify(user));
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && err.status === 401) {
|
||||
setCurrentUser(null);
|
||||
sessionStorage.removeItem("currentUser");
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
}, [refresh]);
|
||||
|
||||
const login = useCallback(async (email: string, password: string) => {
|
||||
const data = await clientFetch<{
|
||||
success: boolean;
|
||||
user?: CurrentUser;
|
||||
accessToken?: string;
|
||||
}>("/auth/login", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
// The NestJS login returns { success, accessToken, user } for JSON clients
|
||||
if (data.user) {
|
||||
// Normalize role to uppercase enum
|
||||
const user: CurrentUser = {
|
||||
sub: data.user.sub || (data.user as unknown as { id?: string }).id || "",
|
||||
email: data.user.email,
|
||||
role: (data.user.role as string).toUpperCase() as UserRole,
|
||||
name: data.user.name,
|
||||
};
|
||||
setCurrentUser(user);
|
||||
sessionStorage.setItem("currentUser", JSON.stringify(user));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const register = useCallback(
|
||||
async (name: string, email: string, password: string) => {
|
||||
const data = await clientFetch<{
|
||||
success: boolean;
|
||||
user?: CurrentUser;
|
||||
}>("/auth/register", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ name, email, password }),
|
||||
});
|
||||
if (data.user) {
|
||||
const user: CurrentUser = {
|
||||
sub: data.user.sub || (data.user as unknown as { id?: string }).id || "",
|
||||
email: data.user.email,
|
||||
role: (data.user.role as string).toUpperCase() as UserRole,
|
||||
name: data.user.name,
|
||||
};
|
||||
setCurrentUser(user);
|
||||
sessionStorage.setItem("currentUser", JSON.stringify(user));
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const logout = useCallback(async () => {
|
||||
try {
|
||||
await clientFetch("/auth/logout", { method: "POST" });
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
setCurrentUser(null);
|
||||
sessionStorage.removeItem("currentUser");
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<AuthContext.Provider
|
||||
value={{ currentUser, loading, login, register, logout, refresh }}
|
||||
>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const ctx = useContext(AuthContext);
|
||||
if (!ctx) throw new Error("useAuth must be used within AuthProvider");
|
||||
return ctx;
|
||||
}
|
||||
86
frontend/lib/types.ts
Normal file
86
frontend/lib/types.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
export enum PostStatus {
|
||||
DRAFT = "draft",
|
||||
PUBLISHED = "published",
|
||||
ARCHIVED = "archived",
|
||||
}
|
||||
|
||||
export enum ContentFormat {
|
||||
MARKDOWN = "markdown",
|
||||
HTML = "html",
|
||||
}
|
||||
|
||||
export enum UserRole {
|
||||
ADMIN = "ADMIN",
|
||||
MANAGER = "MANAGER",
|
||||
MEMBER = "MEMBER",
|
||||
}
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
name?: string;
|
||||
role: UserRole;
|
||||
isActive: boolean;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface FeaturedImage {
|
||||
url: string;
|
||||
alt: string;
|
||||
}
|
||||
|
||||
export interface BlogPost {
|
||||
id: string;
|
||||
title: string;
|
||||
slug: string;
|
||||
status: PostStatus;
|
||||
excerpt: string;
|
||||
content: string;
|
||||
contentFormat: ContentFormat;
|
||||
authorId: string;
|
||||
author?: Pick<User, "id" | "email" | "name" | "role">;
|
||||
featuredImageUrl?: string;
|
||||
featuredImageAlt?: string;
|
||||
isFeatured: boolean;
|
||||
views: number;
|
||||
tags: string[];
|
||||
categories: string[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface PaginatedResult<T> {
|
||||
items: T[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
export interface TagCloudItem {
|
||||
name: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface CreatePostDto {
|
||||
title: string;
|
||||
slug?: string;
|
||||
excerpt?: string;
|
||||
content: string;
|
||||
contentFormat?: ContentFormat;
|
||||
status?: PostStatus;
|
||||
featuredImageUrl?: string;
|
||||
featuredImageAlt?: string;
|
||||
isFeatured?: boolean;
|
||||
tags?: string; // comma-separated string
|
||||
categories?: string; // comma-separated string
|
||||
}
|
||||
|
||||
export interface CurrentUser {
|
||||
sub: string;
|
||||
email: string;
|
||||
role: UserRole;
|
||||
name?: string;
|
||||
iat?: number;
|
||||
exp?: number;
|
||||
}
|
||||
6
frontend/lib/utils.ts
Normal file
6
frontend/lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
Reference in New Issue
Block a user