Add Login and Dashboard

This commit is contained in:
Thanakarn Klangkasame
2025-10-05 19:17:42 +07:00
parent 594e0d42d1
commit 7fb60ba0f5
26 changed files with 1750 additions and 243 deletions

View File

@@ -0,0 +1,145 @@
import { NextRequest, NextResponse } from "next/server";
import { API_BASE, TENANT_KEY, COOKIE_SECURE, COOKIE_SAMESITE } from "@/lib/config";
import type { UpstreamLoginResponse } from "@/types/auth";
const DEBUG_AUTH = (process.env.DEBUG_AUTH ?? "true").toLowerCase() === "true";
const TIMEOUT_MS = Number(process.env.LOGIN_TIMEOUT_MS ?? 15000);
function redact<T extends Record<string, unknown>>(obj: T): T {
try {
if (!obj || typeof obj !== "object") return obj;
const clone = { ...obj } as Record<string, unknown>;
if ("password" in clone) clone.password = "***REDACTED***";
return clone as T;
} catch {
return obj;
}
}
export async function POST(req: NextRequest) {
const url = `${API_BASE}/api/authentication/login`;
let upstreamStatus = 0;
try {
const body = (await req.json().catch(() => ({}))) as Record<string, unknown>;
const printBody = redact(body);
if (DEBUG_AUTH) {
console.log("[auth/login] → upstream", { url, tenant: TENANT_KEY, apiBase: API_BASE, body: printBody });
}
const ac = new AbortController();
const t = setTimeout(() => ac.abort(), TIMEOUT_MS);
const res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json", "X-Tenant": TENANT_KEY },
body: JSON.stringify(body),
redirect: "manual",
signal: ac.signal,
}).catch((e) => {
const err = e as Error;
throw new Error(`Upstream fetch error: ${err.message}`);
});
clearTimeout(t);
upstreamStatus = res.status;
const rawText = await res.text();
let data: UpstreamLoginResponse | { _raw?: string };
try {
data = rawText ? (JSON.parse(rawText) as UpstreamLoginResponse) : ({} as UpstreamLoginResponse);
} catch {
data = { _raw: rawText };
}
if (DEBUG_AUTH) {
const setCookieHeader = res.headers.get("set-cookie");
console.log("[auth/login] ← upstream", {
status: res.status,
hasAccessToken: Boolean((data as UpstreamLoginResponse).access_token),
setCookiePresent: Boolean(setCookieHeader),
setCookiePreview: setCookieHeader?.slice(0, 160),
bodyPreview: rawText.slice(0, 200),
});
}
if (!res.ok) {
return NextResponse.json(
{ message: (data as { message?: string })?.message ?? "Login failed", upstreamStatus: res.status, upstreamBodyPreview: rawText.slice(0, 400) },
{ status: res.status },
);
}
const login = data as UpstreamLoginResponse;
const nextRes = NextResponse.json(
{ user: login.user, token_type: login.token_type, expires_at: login.expires_at },
{ status: 200 },
);
// user_info cookie
try {
const userInfo = {
userId: login?.user?.userId,
email: login?.user?.email,
tenantKey: login?.user?.tenantKey,
tenantId: login?.user?.tenantId,
};
const b64 = Buffer.from(JSON.stringify(userInfo)).toString("base64");
nextRes.cookies.set("user_info", b64, {
httpOnly: false,
secure: COOKIE_SECURE,
sameSite: COOKIE_SAMESITE,
path: "/",
expires: login?.expires_at ? new Date(login.expires_at) : undefined,
});
} catch (e) {
const err = e as Error;
console.warn("[auth/login] could not set user_info cookie:", err.message);
}
if (login?.access_token && login?.expires_at) {
nextRes.cookies.set("access_token", login.access_token, {
httpOnly: false,
secure: COOKIE_SECURE,
sameSite: COOKIE_SAMESITE,
path: "/",
expires: new Date(login.expires_at),
});
}
const setCookieHeader = res.headers.get("set-cookie") ?? "";
const parts = setCookieHeader.split(/,(?=\s*[a-zA-Z0-9_\-]+=)/).map((s) => s.trim());
const refreshPart = parts.find((p) => p.toLowerCase().startsWith("refresh_token="));
if (refreshPart) {
const match = refreshPart.match(/^refresh_token=([^;]+)/i);
if (match) {
const refreshValue = decodeURIComponent(match[1]);
const expMatch = refreshPart.match(/expires=([^;]+)/i);
const expires = expMatch ? new Date(expMatch[1]) : undefined;
nextRes.cookies.set("refresh_token", refreshValue, {
httpOnly: true,
secure: COOKIE_SECURE,
sameSite: COOKIE_SAMESITE,
path: "/",
expires,
});
}
} else if (DEBUG_AUTH) {
console.warn("[auth/login] No refresh_token in upstream Set-Cookie.");
}
return nextRes;
} catch (e) {
const err = e as Error;
const msg = err.name === "AbortError" ? `Upstream timeout > ${TIMEOUT_MS}ms` : err.message;
console.error("[auth/login] ERROR", { message: msg, upstreamStatus });
return NextResponse.json(
{ message: "Server error", detail: msg, upstreamStatus },
{ status: upstreamStatus || 500 },
);
}
}

View File

@@ -0,0 +1,86 @@
import { NextRequest, NextResponse } from "next/server";
import { API_BASE, COOKIE_SECURE, COOKIE_SAMESITE } from "@/lib/config";
import { resolveTenantFromRequest } from "@/lib/server-tenant";
import type { UpstreamRefreshResponse } from "@/types/auth";
const DEBUG_AUTH = (process.env.DEBUG_AUTH ?? "true").toLowerCase() === "true";
export async function POST(req: NextRequest) {
const tenantKey = resolveTenantFromRequest(req);
const url = `${API_BASE}/api/authentication/refresh`;
const refresh = req.cookies.get("refresh_token")?.value;
try {
const r = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Tenant": tenantKey,
...(refresh ? { Cookie: `refresh_token=${encodeURIComponent(refresh)}` } : {}),
},
body: JSON.stringify({}),
redirect: "manual",
});
const rawText = await r.text();
let data: UpstreamRefreshResponse | { _raw?: string };
try {
data = rawText ? (JSON.parse(rawText) as UpstreamRefreshResponse) : ({} as UpstreamRefreshResponse);
} catch {
data = { _raw: rawText };
}
if (DEBUG_AUTH) {
console.log("[auth/refresh] ← upstream", {
status: r.status,
hasAccessToken: Boolean((data as UpstreamRefreshResponse)?.access_token),
bodyPreview: rawText.slice(0, 160),
});
}
if (!r.ok) {
return NextResponse.json({ message: (data as { message?: string })?.message ?? "Refresh failed" }, { status: r.status });
}
const nextRes = NextResponse.json(
{ token_type: (data as UpstreamRefreshResponse).token_type ?? "Bearer", expires_at: (data as UpstreamRefreshResponse).expires_at },
{ status: 200 },
);
if ((data as UpstreamRefreshResponse)?.access_token && (data as UpstreamRefreshResponse)?.expires_at) {
nextRes.cookies.set("access_token", (data as UpstreamRefreshResponse).access_token!, {
httpOnly: false,
secure: COOKIE_SECURE,
sameSite: COOKIE_SAMESITE,
path: "/",
expires: new Date((data as UpstreamRefreshResponse).expires_at!),
});
}
const setCookieHeader = r.headers.get("set-cookie") ?? "";
const parts = setCookieHeader.split(/,(?=\s*[a-zA-Z0-9_\-]+=)/).map((s) => s.trim());
const refreshPart = parts.find((p) => p.toLowerCase().startsWith("refresh_token="));
if (refreshPart) {
const match = refreshPart.match(/^refresh_token=([^;]+)/i);
if (match) {
const refreshValue = decodeURIComponent(match[1]);
const expMatch = refreshPart.match(/expires=([^;]+)/i);
const expires = expMatch ? new Date(expMatch[1]) : undefined;
nextRes.cookies.set("refresh_token", refreshValue, {
httpOnly: true,
secure: COOKIE_SECURE,
sameSite: COOKIE_SAMESITE,
path: "/",
expires,
});
}
}
return nextRes;
} catch (e) {
const err = e as Error;
console.error("[auth/refresh] error:", err.message);
return NextResponse.json({ message: "Server error", detail: err.message }, { status: 500 });
}
}

View File

@@ -0,0 +1,47 @@
// File: src/app/api/public/redeem/route.ts
import { NextRequest, NextResponse } from "next/server";
// ถ้ามี type RedeemRequest/RedeemResponse อยู่แล้ว ใช้ของโปรเจกต์
// import type { RedeemRequest, RedeemResponse } from "@/types/crm";
const API_BASE = process.env.EOP_PUBLIC_API_BASE ?? "http://127.0.0.1:5063";
// ถ้ายังไม่มีไฟล์ types/crm ให้ใช้ fallback แบบหลวมๆ (ไม่ใช่ any)
type RedeemRequestFallback = Record<string, unknown>;
type RedeemResponseFallback = unknown;
export async function POST(req: NextRequest) {
try {
// อ่าน body แบบ unknown แล้วค่อย stringify กลับ — ปลอดภัยกว่า any
const body = (await req.json().catch(() => ({}))) as unknown;
// header จากฝั่ง client
const tenant = req.headers.get("x-tenant-key") ?? "default";
const idem = req.headers.get("idempotency-key") ?? undefined;
const r = await fetch(`${API_BASE}/api/v1/loyalty/redeem`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Tenant-Key": tenant,
...(idem ? { "Idempotency-Key": idem } : {}),
},
body: JSON.stringify(body as RedeemRequestFallback),
cache: "no-store",
});
const text = await r.text();
return new NextResponse(text, {
status: r.status,
headers: {
"Content-Type": r.headers.get("Content-Type") ?? "application/json",
"Cache-Control": "no-store",
},
});
} catch (e: unknown) {
const err = e as Error;
return NextResponse.json(
{ message: err.message ?? "Server error" },
{ status: 500 },
);
}
}

View File

@@ -0,0 +1,53 @@
import { NextRequest, NextResponse } from "next/server";
import { API_BASE } from "@/lib/config";
import { resolveTenantFromRequest } from "@/lib/server-tenant";
type Ctx = { params: Promise<{ path: string[] }> };
function buildHeaders(req: NextRequest, tenantKey: string, extra?: Record<string, string>) {
const access = req.cookies.get("access_token")?.value;
return {
"X-Tenant": tenantKey,
...(access ? { Authorization: `Bearer ${access}` } : {}),
...(extra ?? {}),
};
}
async function forward(req: NextRequest, ctx: Ctx, method: string) {
const { path } = await ctx.params;
const tenantKey = resolveTenantFromRequest(req);
const url = `${API_BASE}/${path.join("/")}${req.nextUrl.search}`;
try {
const init: RequestInit = { method, headers: buildHeaders(req, tenantKey) };
if (method !== "GET" && method !== "HEAD") {
const raw = await req.text();
init.body = raw;
const ct = req.headers.get("content-type");
if (ct) (init.headers as Record<string, string>)["Content-Type"] = ct;
(init.headers as Record<string, string>)["Accept"] = req.headers.get("accept") ?? "application/json";
} else {
(init.headers as Record<string, string>)["Accept"] = req.headers.get("accept") ?? "application/json";
}
const r = await fetch(url, init);
const text = await r.text();
return new NextResponse(text, {
status: r.status,
headers: { "Content-Type": r.headers.get("Content-Type") ?? "application/json" },
});
} catch (e) {
const err = e as Error;
console.error("[proxy]", method, url, "error:", err.message);
return NextResponse.json({ message: "Proxy error", detail: err.message }, { status: 502 });
}
}
export async function GET(req: NextRequest, ctx: Ctx) { return forward(req, ctx, "GET"); }
export async function POST(req: NextRequest, ctx: Ctx) { return forward(req, ctx, "POST"); }
export async function PUT(req: NextRequest, ctx: Ctx) { return forward(req, ctx, "PUT"); }
export async function PATCH(req: NextRequest, ctx: Ctx) { return forward(req, ctx, "PATCH"); }
export async function DELETE(req: NextRequest, ctx: Ctx) { return forward(req, ctx, "DELETE"); }
export async function HEAD(req: NextRequest, ctx: Ctx) { return forward(req, ctx, "HEAD"); }