Apply .gitignore cleanup
This commit is contained in:
966
Bearer-Token.patch.diff
Executable file → Normal file
966
Bearer-Token.patch.diff
Executable file → Normal file
@@ -1,483 +1,483 @@
|
||||
diff --git a/backend/src/middleware/requireBearer.js b/backend/src/middleware/requireBearer.js
|
||||
new file mode 100644
|
||||
index 0000000..1111111
|
||||
--- /dev/null
|
||||
+++ b/backend/src/middleware/requireBearer.js
|
||||
@@ -0,0 +1,44 @@
|
||||
+// backend/src/middleware/requireBearer.js
|
||||
+import jwt from "jsonwebtoken";
|
||||
+import { findUserById } from "../db/models/users.js";
|
||||
+
|
||||
+export async function requireBearer(req, res, next) {
|
||||
+ const hdr = req.get("Authorization") || "";
|
||||
+ const m = hdr.match(/^Bearer\s+(.+)$/i);
|
||||
+ if (!m) return res.status(401).json({ error: "Unauthenticated" });
|
||||
+ try {
|
||||
+ const payload = jwt.verify(m[1], process.env.JWT_ACCESS_SECRET, {
|
||||
+ issuer: "dms-backend",
|
||||
+ });
|
||||
+ const user = await findUserById(payload.user_id);
|
||||
+ if (!user) return res.status(401).json({ error: "Unauthenticated" });
|
||||
+ req.user = {
|
||||
+ user_id: user.user_id,
|
||||
+ username: user.username,
|
||||
+ email: user.email,
|
||||
+ first_name: user.first_name,
|
||||
+ last_name: user.last_name,
|
||||
+ };
|
||||
+ next();
|
||||
+ } catch {
|
||||
+ return res.status(401).json({ error: "Unauthenticated" });
|
||||
+ }
|
||||
+}
|
||||
diff --git a/backend/src/routes/auth.js b/backend/src/routes/auth.js
|
||||
index 2222222..3333333 100644
|
||||
--- a/backend/src/routes/auth.js
|
||||
+++ b/backend/src/routes/auth.js
|
||||
@@ -1,99 +1,109 @@
|
||||
-// (เดิม) ผูกกับคุกกี้ / ส่ง ok:true ฯลฯ
|
||||
+// backend/src/routes/auth.js — Bearer Token ล้วน
|
||||
import { Router } from "express";
|
||||
import jwt from "jsonwebtoken";
|
||||
-import { findUserByUsername } from "../db/models/users.js";
|
||||
+import { findUserByUsername, findUserById } from "../db/models/users.js";
|
||||
import { verifyPassword } from "../utils/passwords.js";
|
||||
-// NOTE: ลบการใช้งาน res.cookie(...) ทั้งหมด
|
||||
+// NOTE: ไม่มีการใช้ res.cookie(...) อีกต่อไป
|
||||
|
||||
const router = Router();
|
||||
|
||||
function signAccessToken(user) {
|
||||
return jwt.sign(
|
||||
{ user_id: user.user_id, username: user.username },
|
||||
process.env.JWT_ACCESS_SECRET,
|
||||
- { issuer: "dms-backend", expiresIn: "30m" } // ปรับได้
|
||||
+ { issuer: "dms-backend", expiresIn: "30m" }
|
||||
);
|
||||
}
|
||||
function signRefreshToken(user) {
|
||||
return jwt.sign(
|
||||
- { user_id: user.user_id, username: user.username },
|
||||
+ { user_id: user.user_id, username: user.username, t: "refresh" },
|
||||
process.env.JWT_REFRESH_SECRET,
|
||||
{ issuer: "dms-backend", expiresIn: "30d" }
|
||||
);
|
||||
}
|
||||
|
||||
router.post("/login", async (req, res) => {
|
||||
const { username, password } = req.body || {};
|
||||
const user = await findUserByUsername(username);
|
||||
if (!user || !(await verifyPassword(password, user.password_hash))) {
|
||||
return res.status(401).json({ error: "INVALID_CREDENTIALS" });
|
||||
}
|
||||
const token = signAccessToken(user);
|
||||
const refresh_token = signRefreshToken(user);
|
||||
return res.json({
|
||||
token,
|
||||
refresh_token,
|
||||
user: {
|
||||
user_id: user.user_id,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
first_name: user.first_name,
|
||||
last_name: user.last_name,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
+router.post("/refresh", async (req, res) => {
|
||||
+ const hdr = req.get("Authorization") || "";
|
||||
+ const m = hdr.match(/^Bearer\s+(.+)$/i);
|
||||
+ const r = m?.[1];
|
||||
+ if (!r) return res.status(401).json({ error: "NO_REFRESH_TOKEN" });
|
||||
+ try {
|
||||
+ const payload = jwt.verify(r, process.env.JWT_REFRESH_SECRET, {
|
||||
+ issuer: "dms-backend",
|
||||
+ });
|
||||
+ const user = await findUserById(payload.user_id);
|
||||
+ if (!user) return res.status(401).json({ error: "USER_NOT_FOUND" });
|
||||
+ const token = signAccessToken(user);
|
||||
+ return res.json({ token });
|
||||
+ } catch {
|
||||
+ return res.status(401).json({ error: "INVALID_REFRESH_TOKEN" });
|
||||
+ }
|
||||
+});
|
||||
+
|
||||
export default router;
|
||||
diff --git a/backend/src/index.js b/backend/src/index.js
|
||||
index 4444444..5555555 100644
|
||||
--- a/backend/src/index.js
|
||||
+++ b/backend/src/index.js
|
||||
@@ -1,60 +1,69 @@
|
||||
import express from "express";
|
||||
import cors from "cors";
|
||||
import authRouter from "./routes/auth.js";
|
||||
+import { requireBearer } from "./middleware/requireBearer.js";
|
||||
-// import routers อื่น ๆ ตามจริง เช่น rfasRouter, transmittalsRouter
|
||||
|
||||
const app = express();
|
||||
|
||||
-// CORS เดิม (อาจมี credentials)
|
||||
-app.use(cors({
|
||||
- origin: true,
|
||||
- credentials: true,
|
||||
-}));
|
||||
+// ✅ CORS สำหรับ Bearer: ไม่ต้อง credentials, อนุญาต Authorization header
|
||||
+app.use(cors({
|
||||
+ origin: [
|
||||
+ "https://lcbp3.np-dms.work",
|
||||
+ "http://localhost:3000"
|
||||
+ ],
|
||||
+ methods: ["GET","POST","PUT","PATCH","DELETE","OPTIONS"],
|
||||
+ allowedHeaders: ["Authorization","Content-Type","Accept","Origin","Referer","User-Agent","X-Requested-With","Cache-Control","Pragma"],
|
||||
+ exposedHeaders: ["Content-Disposition","Content-Length"]
|
||||
+}));
|
||||
|
||||
app.use(express.json());
|
||||
|
||||
-// routes เดิม
|
||||
-app.use("/api/auth", authRouter);
|
||||
-// app.use("/api/rfas", rfasRouter);
|
||||
-// app.use("/api/transmittals", transmittalsRouter);
|
||||
+// ✅ เส้นทาง auth (ไม่ต้องมี token)
|
||||
+app.use("/api/auth", authRouter);
|
||||
+
|
||||
+// ✅ ตั้ง guard สำหรับเส้นทางที่เหลือต้องล็อกอิน
|
||||
+app.use("/api", requireBearer);
|
||||
+// แล้วค่อย mount routers protected ใต้ /api
|
||||
+// app.use("/api/rfas", rfasRouter);
|
||||
+// app.use("/api/transmittals", transmittalsRouter);
|
||||
|
||||
app.use((err, _req, res, _next) => {
|
||||
console.error(err);
|
||||
res.status(500).json({ error: "INTERNAL_SERVER_ERROR" });
|
||||
});
|
||||
|
||||
const port = process.env.PORT || 4000;
|
||||
app.listen(port, () => console.log(`backend listening on :${port}`));
|
||||
diff --git a/frontend/app/(auth)/login/page.jsx b/frontend/app/(auth)/login/page.jsx
|
||||
index 6666666..7777777 100644
|
||||
--- a/frontend/app/(auth)/login/page.jsx
|
||||
+++ b/frontend/app/(auth)/login/page.jsx
|
||||
@@ -1,200 +1,236 @@
|
||||
// File: frontend/app/(auth)/login/page.jsx
|
||||
"use client";
|
||||
|
||||
-// เวอร์ชันเดิม
|
||||
+// ✅ Bearer-only + Debug toggle (NEXT_PUBLIC_DEBUG_AUTH)
|
||||
import { useState, useMemo, Suspense } from "react";
|
||||
import { useSearchParams, useRouter } from "next/navigation";
|
||||
import {
|
||||
Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter,
|
||||
} from "@/components/ui/card";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
|
||||
-const API_BASE = process.env.NEXT_PUBLIC_API_BASE?.replace(/\/$/, "") || "";
|
||||
+const API_BASE = process.env.NEXT_PUBLIC_API_BASE?.replace(/\/$/, "") || "";
|
||||
+const DEBUG =
|
||||
+ String(process.env.NEXT_PUBLIC_DEBUG_AUTH || "").trim() !== "" &&
|
||||
+ process.env.NEXT_PUBLIC_DEBUG_AUTH !== "0" &&
|
||||
+ process.env.NEXT_PUBLIC_DEBUG_AUTH !== "false";
|
||||
+function dlog(...args) {
|
||||
+ if (DEBUG && typeof window !== "undefined") console.debug("[login]", ...args);
|
||||
+}
|
||||
|
||||
function LoginForm() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const nextPath = useMemo(
|
||||
() => searchParams.get("next") || "/dashboard",
|
||||
[searchParams]
|
||||
);
|
||||
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [showPw, setShowPw] = useState(false);
|
||||
const [remember, setRemember] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [err, setErr] = useState("");
|
||||
|
||||
async function onSubmit(e) {
|
||||
e.preventDefault();
|
||||
setErr("");
|
||||
if (!username.trim() || !password) {
|
||||
setErr("กรอกชื่อผู้ใช้และรหัสผ่านให้ครบ");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setSubmitting(true);
|
||||
+ dlog("API_BASE =", API_BASE || "(empty → relative)");
|
||||
+ dlog("nextPath =", nextPath, "remember =", remember);
|
||||
|
||||
const res = await fetch(`${API_BASE}/api/auth/login`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username, password }),
|
||||
cache: "no-store",
|
||||
});
|
||||
- const data = await res.json().catch(() => ({}));
|
||||
+ dlog("response.status =", res.status);
|
||||
+ dlog("response.headers.content-type =", res.headers.get("content-type"));
|
||||
+ let data = {};
|
||||
+ try { data = await res.json(); } catch (e) { dlog("response.json() error =", e); }
|
||||
+ dlog("response.body =", data);
|
||||
|
||||
if (!res.ok) {
|
||||
- setErr(data?.error || "เข้าสู่ระบบไม่สำเร็จ");
|
||||
+ const msg =
|
||||
+ data?.error === "INVALID_CREDENTIALS"
|
||||
+ ? "ชื่อผู้ใช้หรือรหัสผ่านไม่ถูกต้อง"
|
||||
+ : data?.error || `เข้าสู่ระบบไม่สำเร็จ (HTTP ${res.status})`;
|
||||
+ dlog("login FAILED →", msg);
|
||||
+ setErr(msg);
|
||||
return;
|
||||
}
|
||||
+ if (!data?.token) {
|
||||
+ dlog("login FAILED → data.token not found");
|
||||
+ setErr("รูปแบบข้อมูลตอบกลับไม่ถูกต้อง (ไม่มี token)");
|
||||
+ return;
|
||||
+ }
|
||||
|
||||
const storage = remember ? window.localStorage : window.sessionStorage;
|
||||
storage.setItem("dms.token", data.token);
|
||||
storage.setItem("dms.refresh_token", data.refresh_token);
|
||||
storage.setItem("dms.user", JSON.stringify(data.user || {}));
|
||||
+ dlog("token stored in", remember ? "localStorage" : "sessionStorage");
|
||||
|
||||
try {
|
||||
window.dispatchEvent(
|
||||
new StorageEvent("storage", { key: "dms.auth", newValue: "login" })
|
||||
);
|
||||
} catch {}
|
||||
- router.replace(nextPath);
|
||||
+ dlog("navigating →", nextPath);
|
||||
+ router.replace(nextPath);
|
||||
} catch (e) {
|
||||
+ dlog("exception =", e);
|
||||
setErr("เชื่อมต่อเซิร์ฟเวอร์ไม่ได้ กรุณาลองใหม่");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
+ dlog("done");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid min-h-[calc(100vh-4rem)] place-items-center p-4">
|
||||
<Card className="w-full max-w-md border-0 shadow-xl ring-1 ring-black/5 bg-white/90 backdrop-blur">
|
||||
<CardHeader className="space-y-1">
|
||||
<CardTitle className="text-2xl font-bold text-sky-800">เข้าสู่ระบบ</CardTitle>
|
||||
<CardDescription className="text-sky-700">Document Management System • LCBP3</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{err ? (
|
||||
<Alert className="mb-4"><AlertDescription>{err}</AlertDescription></Alert>
|
||||
) : null}
|
||||
<form onSubmit={onSubmit} className="grid gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="username">ชื่อผู้ใช้</Label>
|
||||
<Input id="username" autoFocus autoComplete="username" value={username}
|
||||
onChange={(e) => setUsername(e.target.value)} placeholder="เช่น superadmin" disabled={submitting}/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="password">รหัสผ่าน</Label>
|
||||
<div className="relative">
|
||||
<Input id="password" type={showPw ? "text" : "password"} autoComplete="current-password"
|
||||
value={password} onChange={(e) => setPassword(e.target.value)} placeholder="••••••••"
|
||||
disabled={submitting} className="pr-10"/>
|
||||
<button type="button" onClick={() => setShowPw((v) => !v)}
|
||||
className="absolute inset-y-0 px-2 my-auto text-xs bg-white border rounded-md right-2 hover:bg-slate-50"
|
||||
aria-label={showPw ? "ซ่อนรหัสผ่าน" : "แสดงรหัสผ่าน"} disabled={submitting}>
|
||||
{showPw ? "Hide" : "Show"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between pt-1">
|
||||
<label className="inline-flex items-center gap-2 text-sm text-slate-600">
|
||||
<input type="checkbox" className="size-4 accent-sky-700"
|
||||
checked={remember} onChange={(e) => setRemember(e.target.checked)} disabled={submitting}/>
|
||||
จดจำฉันไว้ในเครื่องนี้
|
||||
</label>
|
||||
<a href="/forgot-password" className="text-sm text-sky-700 hover:text-sky-900 hover:underline">ลืมรหัสผ่าน?</a>
|
||||
</div>
|
||||
<Button type="submit" disabled={submitting} className="mt-2 bg-sky-700 hover:bg-sky-800">
|
||||
{submitting ? (<span className="inline-flex items-center gap-2"><Spinner /> กำลังเข้าสู่ระบบ…</span>) : ("เข้าสู่ระบบ")}
|
||||
</Button>
|
||||
+ {DEBUG ? (
|
||||
+ <p className="mt-2 text-xs text-slate-500">
|
||||
+ DEBUG: NEXT_PUBLIC_API_BASE = <code>{API_BASE || "(empty)"}</code>
|
||||
+ </p>
|
||||
+ ) : null}
|
||||
</form>
|
||||
</CardContent>
|
||||
<CardFooter className="text-xs text-center text-slate-500">
|
||||
© {new Date().getFullYear()} np-dms.work
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function LoginPage() {
|
||||
return (
|
||||
<Suspense fallback={<LoginPageSkeleton />}>
|
||||
<LoginForm />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
function LoginPageSkeleton() {
|
||||
return (
|
||||
<div className="grid min-h-[calc(100vh-4rem)] place-items-center p-4">
|
||||
<Card className="w-full max-w-md border-0 shadow-xl ring-1 ring-black/5 bg-white/90 backdrop-blur">
|
||||
<CardHeader className="space-y-1">
|
||||
<CardTitle className="text-2xl font-bold text-sky-800">เข้าสู่ระบบ</CardTitle>
|
||||
<CardDescription className="text-sky-700">Document Management System • LCBP3</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid gap-4 animate-pulse">
|
||||
<div className="h-10 rounded bg-slate-200"></div>
|
||||
<div className="h-10 rounded bg-slate-200"></div>
|
||||
<div className="h-10 rounded bg-slate-200"></div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Spinner() {
|
||||
return (
|
||||
<svg className="animate-spin size-4" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
diff --git a/frontend/app/(protected)/layout.jsx b/frontend/app/(protected)/layout.jsx
|
||||
new file mode 100644
|
||||
index 0000000..8888888
|
||||
--- /dev/null
|
||||
+++ b/frontend/app/(protected)/layout.jsx
|
||||
@@ -0,0 +1,38 @@
|
||||
+"use client";
|
||||
+import { useEffect, useState } from "react";
|
||||
+import { usePathname, useRouter } from "next/navigation";
|
||||
+
|
||||
+export default function ProtectedLayout({ children }) {
|
||||
+ const router = useRouter();
|
||||
+ const pathname = usePathname();
|
||||
+ const [ready, setReady] = useState(false);
|
||||
+
|
||||
+ useEffect(() => {
|
||||
+ try {
|
||||
+ const token =
|
||||
+ (typeof window !== "undefined" &&
|
||||
+ (localStorage.getItem("dms.token") ||
|
||||
+ sessionStorage.getItem("dms.token"))) ||
|
||||
+ null;
|
||||
+ if (!token) {
|
||||
+ const next = encodeURIComponent(pathname || "/dashboard");
|
||||
+ router.replace(`/login?next=${next}`);
|
||||
+ return;
|
||||
+ }
|
||||
+ } finally {
|
||||
+ setReady(true);
|
||||
+ }
|
||||
+ }, [pathname, router]);
|
||||
+
|
||||
+ if (!ready) {
|
||||
+ return (
|
||||
+ <div className="grid min-h-[calc(100vh-4rem)] place-items-center p-6 text-slate-600">
|
||||
+ กำลังตรวจสิทธิ์…
|
||||
+ </div>
|
||||
+ );
|
||||
+ }
|
||||
+ return <>{children}</>;
|
||||
+}
|
||||
diff --git a/frontend/lib/api.js b/frontend/lib/api.js
|
||||
new file mode 100644
|
||||
index 0000000..9999999
|
||||
--- /dev/null
|
||||
+++ b/frontend/lib/api.js
|
||||
@@ -0,0 +1,45 @@
|
||||
+// frontend/lib/api.js
|
||||
+const API_BASE = process.env.NEXT_PUBLIC_API_BASE?.replace(/\/$/, "") || "";
|
||||
+
|
||||
+function getToken() {
|
||||
+ if (typeof window === "undefined") return null;
|
||||
+ return localStorage.getItem("dms.token") || sessionStorage.getItem("dms.token");
|
||||
+}
|
||||
+
|
||||
+export async function apiFetch(path, options = {}) {
|
||||
+ const token = getToken();
|
||||
+ const headers = new Headers(options.headers || {});
|
||||
+ headers.set("Accept", "application/json");
|
||||
+ if (!headers.has("Content-Type")) headers.set("Content-Type", "application/json");
|
||||
+ if (token) headers.set("Authorization", `Bearer ${token}`);
|
||||
+
|
||||
+ const res = await fetch(`${API_BASE}${path}`, { ...options, headers, cache: "no-store" });
|
||||
+
|
||||
+ if (res.status === 401) {
|
||||
+ const refresh =
|
||||
+ localStorage.getItem("dms.refresh_token") || sessionStorage.getItem("dms.refresh_token");
|
||||
+ if (refresh) {
|
||||
+ const r = await fetch(`${API_BASE}/api/auth/refresh`, {
|
||||
+ method: "POST",
|
||||
+ headers: { Authorization: `Bearer ${refresh}` },
|
||||
+ });
|
||||
+ if (r.ok) {
|
||||
+ const { token: newToken } = await r.json();
|
||||
+ const store = localStorage.getItem("dms.refresh_token") ? localStorage : sessionStorage;
|
||||
+ store.setItem("dms.token", newToken);
|
||||
+ const headers2 = new Headers(headers);
|
||||
+ headers2.set("Authorization", `Bearer ${newToken}`);
|
||||
+ return fetch(`${API_BASE}${path}`, { ...options, headers: headers2, cache: "no-store" });
|
||||
+ }
|
||||
+ }
|
||||
+ }
|
||||
+ return res;
|
||||
+}
|
||||
diff --git a/frontend/middleware.ts b/frontend/middleware.ts
|
||||
index aaaaaaa..bbbbbbb 100644
|
||||
--- a/frontend/middleware.ts
|
||||
+++ b/frontend/middleware.ts
|
||||
@@ -1,15 +1,14 @@
|
||||
-import { NextResponse } from "next/server";
|
||||
-import type { NextRequest } from "next/server";
|
||||
-
|
||||
-// เดิม: ตรวจคุกกี้แล้ว redirect /dashboard
|
||||
-export function middleware(req: NextRequest) {
|
||||
- // ... logic เดิมที่ใช้คุกกี้
|
||||
- return NextResponse.next();
|
||||
-}
|
||||
-
|
||||
-export const config = {
|
||||
- matcher: ["/(protected/:path*)","/dashboard","/users/:path*","/api/:path*"],
|
||||
-};
|
||||
+import { NextResponse } from "next/server";
|
||||
+// ✅ ไม่บล็อกเพจอีกต่อไป (Bearer อยู่ใน storage ฝั่ง client)
|
||||
+export function middleware() {
|
||||
+ return NextResponse.next();
|
||||
+}
|
||||
+// จำกัดให้ทำงานเฉพาะ /api ถ้าต้องการใช้ในอนาคต (ตอนนี้ผ่านเฉย ๆ)
|
||||
+export const config = { matcher: ["/api/:path*"] };
|
||||
diff --git a/frontend/app/(protected)/dashboard/page.jsx b/frontend/app/(protected)/dashboard/page.jsx
|
||||
new file mode 100644
|
||||
index 0000000..ccccccc
|
||||
--- /dev/null
|
||||
+++ b/frontend/app/(protected)/dashboard/page.jsx
|
||||
@@ -0,0 +1,11 @@
|
||||
+"use client";
|
||||
+export default function DashboardPage() {
|
||||
+ return (
|
||||
+ <main className="p-6">
|
||||
+ <h1 className="text-2xl font-semibold text-sky-800">Dashboard</h1>
|
||||
+ <p className="text-slate-600 mt-2">
|
||||
+ ยินดีต้อนรับสู่ DMS
|
||||
+ </p>
|
||||
+ </main>
|
||||
+ );
|
||||
+}
|
||||
diff --git a/backend/src/middleware/requireBearer.js b/backend/src/middleware/requireBearer.js
|
||||
new file mode 100644
|
||||
index 0000000..1111111
|
||||
--- /dev/null
|
||||
+++ b/backend/src/middleware/requireBearer.js
|
||||
@@ -0,0 +1,44 @@
|
||||
+// backend/src/middleware/requireBearer.js
|
||||
+import jwt from "jsonwebtoken";
|
||||
+import { findUserById } from "../db/models/users.js";
|
||||
+
|
||||
+export async function requireBearer(req, res, next) {
|
||||
+ const hdr = req.get("Authorization") || "";
|
||||
+ const m = hdr.match(/^Bearer\s+(.+)$/i);
|
||||
+ if (!m) return res.status(401).json({ error: "Unauthenticated" });
|
||||
+ try {
|
||||
+ const payload = jwt.verify(m[1], process.env.JWT_ACCESS_SECRET, {
|
||||
+ issuer: "dms-backend",
|
||||
+ });
|
||||
+ const user = await findUserById(payload.user_id);
|
||||
+ if (!user) return res.status(401).json({ error: "Unauthenticated" });
|
||||
+ req.user = {
|
||||
+ user_id: user.user_id,
|
||||
+ username: user.username,
|
||||
+ email: user.email,
|
||||
+ first_name: user.first_name,
|
||||
+ last_name: user.last_name,
|
||||
+ };
|
||||
+ next();
|
||||
+ } catch {
|
||||
+ return res.status(401).json({ error: "Unauthenticated" });
|
||||
+ }
|
||||
+}
|
||||
diff --git a/backend/src/routes/auth.js b/backend/src/routes/auth.js
|
||||
index 2222222..3333333 100644
|
||||
--- a/backend/src/routes/auth.js
|
||||
+++ b/backend/src/routes/auth.js
|
||||
@@ -1,99 +1,109 @@
|
||||
-// (เดิม) ผูกกับคุกกี้ / ส่ง ok:true ฯลฯ
|
||||
+// backend/src/routes/auth.js — Bearer Token ล้วน
|
||||
import { Router } from "express";
|
||||
import jwt from "jsonwebtoken";
|
||||
-import { findUserByUsername } from "../db/models/users.js";
|
||||
+import { findUserByUsername, findUserById } from "../db/models/users.js";
|
||||
import { verifyPassword } from "../utils/passwords.js";
|
||||
-// NOTE: ลบการใช้งาน res.cookie(...) ทั้งหมด
|
||||
+// NOTE: ไม่มีการใช้ res.cookie(...) อีกต่อไป
|
||||
|
||||
const router = Router();
|
||||
|
||||
function signAccessToken(user) {
|
||||
return jwt.sign(
|
||||
{ user_id: user.user_id, username: user.username },
|
||||
process.env.JWT_ACCESS_SECRET,
|
||||
- { issuer: "dms-backend", expiresIn: "30m" } // ปรับได้
|
||||
+ { issuer: "dms-backend", expiresIn: "30m" }
|
||||
);
|
||||
}
|
||||
function signRefreshToken(user) {
|
||||
return jwt.sign(
|
||||
- { user_id: user.user_id, username: user.username },
|
||||
+ { user_id: user.user_id, username: user.username, t: "refresh" },
|
||||
process.env.JWT_REFRESH_SECRET,
|
||||
{ issuer: "dms-backend", expiresIn: "30d" }
|
||||
);
|
||||
}
|
||||
|
||||
router.post("/login", async (req, res) => {
|
||||
const { username, password } = req.body || {};
|
||||
const user = await findUserByUsername(username);
|
||||
if (!user || !(await verifyPassword(password, user.password_hash))) {
|
||||
return res.status(401).json({ error: "INVALID_CREDENTIALS" });
|
||||
}
|
||||
const token = signAccessToken(user);
|
||||
const refresh_token = signRefreshToken(user);
|
||||
return res.json({
|
||||
token,
|
||||
refresh_token,
|
||||
user: {
|
||||
user_id: user.user_id,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
first_name: user.first_name,
|
||||
last_name: user.last_name,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
+router.post("/refresh", async (req, res) => {
|
||||
+ const hdr = req.get("Authorization") || "";
|
||||
+ const m = hdr.match(/^Bearer\s+(.+)$/i);
|
||||
+ const r = m?.[1];
|
||||
+ if (!r) return res.status(401).json({ error: "NO_REFRESH_TOKEN" });
|
||||
+ try {
|
||||
+ const payload = jwt.verify(r, process.env.JWT_REFRESH_SECRET, {
|
||||
+ issuer: "dms-backend",
|
||||
+ });
|
||||
+ const user = await findUserById(payload.user_id);
|
||||
+ if (!user) return res.status(401).json({ error: "USER_NOT_FOUND" });
|
||||
+ const token = signAccessToken(user);
|
||||
+ return res.json({ token });
|
||||
+ } catch {
|
||||
+ return res.status(401).json({ error: "INVALID_REFRESH_TOKEN" });
|
||||
+ }
|
||||
+});
|
||||
+
|
||||
export default router;
|
||||
diff --git a/backend/src/index.js b/backend/src/index.js
|
||||
index 4444444..5555555 100644
|
||||
--- a/backend/src/index.js
|
||||
+++ b/backend/src/index.js
|
||||
@@ -1,60 +1,69 @@
|
||||
import express from "express";
|
||||
import cors from "cors";
|
||||
import authRouter from "./routes/auth.js";
|
||||
+import { requireBearer } from "./middleware/requireBearer.js";
|
||||
-// import routers อื่น ๆ ตามจริง เช่น rfasRouter, transmittalsRouter
|
||||
|
||||
const app = express();
|
||||
|
||||
-// CORS เดิม (อาจมี credentials)
|
||||
-app.use(cors({
|
||||
- origin: true,
|
||||
- credentials: true,
|
||||
-}));
|
||||
+// ✅ CORS สำหรับ Bearer: ไม่ต้อง credentials, อนุญาต Authorization header
|
||||
+app.use(cors({
|
||||
+ origin: [
|
||||
+ "https://lcbp3.np-dms.work",
|
||||
+ "http://localhost:3000"
|
||||
+ ],
|
||||
+ methods: ["GET","POST","PUT","PATCH","DELETE","OPTIONS"],
|
||||
+ allowedHeaders: ["Authorization","Content-Type","Accept","Origin","Referer","User-Agent","X-Requested-With","Cache-Control","Pragma"],
|
||||
+ exposedHeaders: ["Content-Disposition","Content-Length"]
|
||||
+}));
|
||||
|
||||
app.use(express.json());
|
||||
|
||||
-// routes เดิม
|
||||
-app.use("/api/auth", authRouter);
|
||||
-// app.use("/api/rfas", rfasRouter);
|
||||
-// app.use("/api/transmittals", transmittalsRouter);
|
||||
+// ✅ เส้นทาง auth (ไม่ต้องมี token)
|
||||
+app.use("/api/auth", authRouter);
|
||||
+
|
||||
+// ✅ ตั้ง guard สำหรับเส้นทางที่เหลือต้องล็อกอิน
|
||||
+app.use("/api", requireBearer);
|
||||
+// แล้วค่อย mount routers protected ใต้ /api
|
||||
+// app.use("/api/rfas", rfasRouter);
|
||||
+// app.use("/api/transmittals", transmittalsRouter);
|
||||
|
||||
app.use((err, _req, res, _next) => {
|
||||
console.error(err);
|
||||
res.status(500).json({ error: "INTERNAL_SERVER_ERROR" });
|
||||
});
|
||||
|
||||
const port = process.env.PORT || 4000;
|
||||
app.listen(port, () => console.log(`backend listening on :${port}`));
|
||||
diff --git a/frontend/app/(auth)/login/page.jsx b/frontend/app/(auth)/login/page.jsx
|
||||
index 6666666..7777777 100644
|
||||
--- a/frontend/app/(auth)/login/page.jsx
|
||||
+++ b/frontend/app/(auth)/login/page.jsx
|
||||
@@ -1,200 +1,236 @@
|
||||
// File: frontend/app/(auth)/login/page.jsx
|
||||
"use client";
|
||||
|
||||
-// เวอร์ชันเดิม
|
||||
+// ✅ Bearer-only + Debug toggle (NEXT_PUBLIC_DEBUG_AUTH)
|
||||
import { useState, useMemo, Suspense } from "react";
|
||||
import { useSearchParams, useRouter } from "next/navigation";
|
||||
import {
|
||||
Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter,
|
||||
} from "@/components/ui/card";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
|
||||
-const API_BASE = process.env.NEXT_PUBLIC_API_BASE?.replace(/\/$/, "") || "";
|
||||
+const API_BASE = process.env.NEXT_PUBLIC_API_BASE?.replace(/\/$/, "") || "";
|
||||
+const DEBUG =
|
||||
+ String(process.env.NEXT_PUBLIC_DEBUG_AUTH || "").trim() !== "" &&
|
||||
+ process.env.NEXT_PUBLIC_DEBUG_AUTH !== "0" &&
|
||||
+ process.env.NEXT_PUBLIC_DEBUG_AUTH !== "false";
|
||||
+function dlog(...args) {
|
||||
+ if (DEBUG && typeof window !== "undefined") console.debug("[login]", ...args);
|
||||
+}
|
||||
|
||||
function LoginForm() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const nextPath = useMemo(
|
||||
() => searchParams.get("next") || "/dashboard",
|
||||
[searchParams]
|
||||
);
|
||||
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [showPw, setShowPw] = useState(false);
|
||||
const [remember, setRemember] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [err, setErr] = useState("");
|
||||
|
||||
async function onSubmit(e) {
|
||||
e.preventDefault();
|
||||
setErr("");
|
||||
if (!username.trim() || !password) {
|
||||
setErr("กรอกชื่อผู้ใช้และรหัสผ่านให้ครบ");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setSubmitting(true);
|
||||
+ dlog("API_BASE =", API_BASE || "(empty → relative)");
|
||||
+ dlog("nextPath =", nextPath, "remember =", remember);
|
||||
|
||||
const res = await fetch(`${API_BASE}/api/auth/login`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username, password }),
|
||||
cache: "no-store",
|
||||
});
|
||||
- const data = await res.json().catch(() => ({}));
|
||||
+ dlog("response.status =", res.status);
|
||||
+ dlog("response.headers.content-type =", res.headers.get("content-type"));
|
||||
+ let data = {};
|
||||
+ try { data = await res.json(); } catch (e) { dlog("response.json() error =", e); }
|
||||
+ dlog("response.body =", data);
|
||||
|
||||
if (!res.ok) {
|
||||
- setErr(data?.error || "เข้าสู่ระบบไม่สำเร็จ");
|
||||
+ const msg =
|
||||
+ data?.error === "INVALID_CREDENTIALS"
|
||||
+ ? "ชื่อผู้ใช้หรือรหัสผ่านไม่ถูกต้อง"
|
||||
+ : data?.error || `เข้าสู่ระบบไม่สำเร็จ (HTTP ${res.status})`;
|
||||
+ dlog("login FAILED →", msg);
|
||||
+ setErr(msg);
|
||||
return;
|
||||
}
|
||||
+ if (!data?.token) {
|
||||
+ dlog("login FAILED → data.token not found");
|
||||
+ setErr("รูปแบบข้อมูลตอบกลับไม่ถูกต้อง (ไม่มี token)");
|
||||
+ return;
|
||||
+ }
|
||||
|
||||
const storage = remember ? window.localStorage : window.sessionStorage;
|
||||
storage.setItem("dms.token", data.token);
|
||||
storage.setItem("dms.refresh_token", data.refresh_token);
|
||||
storage.setItem("dms.user", JSON.stringify(data.user || {}));
|
||||
+ dlog("token stored in", remember ? "localStorage" : "sessionStorage");
|
||||
|
||||
try {
|
||||
window.dispatchEvent(
|
||||
new StorageEvent("storage", { key: "dms.auth", newValue: "login" })
|
||||
);
|
||||
} catch {}
|
||||
- router.replace(nextPath);
|
||||
+ dlog("navigating →", nextPath);
|
||||
+ router.replace(nextPath);
|
||||
} catch (e) {
|
||||
+ dlog("exception =", e);
|
||||
setErr("เชื่อมต่อเซิร์ฟเวอร์ไม่ได้ กรุณาลองใหม่");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
+ dlog("done");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid min-h-[calc(100vh-4rem)] place-items-center p-4">
|
||||
<Card className="w-full max-w-md border-0 shadow-xl ring-1 ring-black/5 bg-white/90 backdrop-blur">
|
||||
<CardHeader className="space-y-1">
|
||||
<CardTitle className="text-2xl font-bold text-sky-800">เข้าสู่ระบบ</CardTitle>
|
||||
<CardDescription className="text-sky-700">Document Management System • LCBP3</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{err ? (
|
||||
<Alert className="mb-4"><AlertDescription>{err}</AlertDescription></Alert>
|
||||
) : null}
|
||||
<form onSubmit={onSubmit} className="grid gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="username">ชื่อผู้ใช้</Label>
|
||||
<Input id="username" autoFocus autoComplete="username" value={username}
|
||||
onChange={(e) => setUsername(e.target.value)} placeholder="เช่น superadmin" disabled={submitting}/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="password">รหัสผ่าน</Label>
|
||||
<div className="relative">
|
||||
<Input id="password" type={showPw ? "text" : "password"} autoComplete="current-password"
|
||||
value={password} onChange={(e) => setPassword(e.target.value)} placeholder="••••••••"
|
||||
disabled={submitting} className="pr-10"/>
|
||||
<button type="button" onClick={() => setShowPw((v) => !v)}
|
||||
className="absolute inset-y-0 px-2 my-auto text-xs bg-white border rounded-md right-2 hover:bg-slate-50"
|
||||
aria-label={showPw ? "ซ่อนรหัสผ่าน" : "แสดงรหัสผ่าน"} disabled={submitting}>
|
||||
{showPw ? "Hide" : "Show"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between pt-1">
|
||||
<label className="inline-flex items-center gap-2 text-sm text-slate-600">
|
||||
<input type="checkbox" className="size-4 accent-sky-700"
|
||||
checked={remember} onChange={(e) => setRemember(e.target.checked)} disabled={submitting}/>
|
||||
จดจำฉันไว้ในเครื่องนี้
|
||||
</label>
|
||||
<a href="/forgot-password" className="text-sm text-sky-700 hover:text-sky-900 hover:underline">ลืมรหัสผ่าน?</a>
|
||||
</div>
|
||||
<Button type="submit" disabled={submitting} className="mt-2 bg-sky-700 hover:bg-sky-800">
|
||||
{submitting ? (<span className="inline-flex items-center gap-2"><Spinner /> กำลังเข้าสู่ระบบ…</span>) : ("เข้าสู่ระบบ")}
|
||||
</Button>
|
||||
+ {DEBUG ? (
|
||||
+ <p className="mt-2 text-xs text-slate-500">
|
||||
+ DEBUG: NEXT_PUBLIC_API_BASE = <code>{API_BASE || "(empty)"}</code>
|
||||
+ </p>
|
||||
+ ) : null}
|
||||
</form>
|
||||
</CardContent>
|
||||
<CardFooter className="text-xs text-center text-slate-500">
|
||||
© {new Date().getFullYear()} np-dms.work
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function LoginPage() {
|
||||
return (
|
||||
<Suspense fallback={<LoginPageSkeleton />}>
|
||||
<LoginForm />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
function LoginPageSkeleton() {
|
||||
return (
|
||||
<div className="grid min-h-[calc(100vh-4rem)] place-items-center p-4">
|
||||
<Card className="w-full max-w-md border-0 shadow-xl ring-1 ring-black/5 bg-white/90 backdrop-blur">
|
||||
<CardHeader className="space-y-1">
|
||||
<CardTitle className="text-2xl font-bold text-sky-800">เข้าสู่ระบบ</CardTitle>
|
||||
<CardDescription className="text-sky-700">Document Management System • LCBP3</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid gap-4 animate-pulse">
|
||||
<div className="h-10 rounded bg-slate-200"></div>
|
||||
<div className="h-10 rounded bg-slate-200"></div>
|
||||
<div className="h-10 rounded bg-slate-200"></div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Spinner() {
|
||||
return (
|
||||
<svg className="animate-spin size-4" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
diff --git a/frontend/app/(protected)/layout.jsx b/frontend/app/(protected)/layout.jsx
|
||||
new file mode 100644
|
||||
index 0000000..8888888
|
||||
--- /dev/null
|
||||
+++ b/frontend/app/(protected)/layout.jsx
|
||||
@@ -0,0 +1,38 @@
|
||||
+"use client";
|
||||
+import { useEffect, useState } from "react";
|
||||
+import { usePathname, useRouter } from "next/navigation";
|
||||
+
|
||||
+export default function ProtectedLayout({ children }) {
|
||||
+ const router = useRouter();
|
||||
+ const pathname = usePathname();
|
||||
+ const [ready, setReady] = useState(false);
|
||||
+
|
||||
+ useEffect(() => {
|
||||
+ try {
|
||||
+ const token =
|
||||
+ (typeof window !== "undefined" &&
|
||||
+ (localStorage.getItem("dms.token") ||
|
||||
+ sessionStorage.getItem("dms.token"))) ||
|
||||
+ null;
|
||||
+ if (!token) {
|
||||
+ const next = encodeURIComponent(pathname || "/dashboard");
|
||||
+ router.replace(`/login?next=${next}`);
|
||||
+ return;
|
||||
+ }
|
||||
+ } finally {
|
||||
+ setReady(true);
|
||||
+ }
|
||||
+ }, [pathname, router]);
|
||||
+
|
||||
+ if (!ready) {
|
||||
+ return (
|
||||
+ <div className="grid min-h-[calc(100vh-4rem)] place-items-center p-6 text-slate-600">
|
||||
+ กำลังตรวจสิทธิ์…
|
||||
+ </div>
|
||||
+ );
|
||||
+ }
|
||||
+ return <>{children}</>;
|
||||
+}
|
||||
diff --git a/frontend/lib/api.js b/frontend/lib/api.js
|
||||
new file mode 100644
|
||||
index 0000000..9999999
|
||||
--- /dev/null
|
||||
+++ b/frontend/lib/api.js
|
||||
@@ -0,0 +1,45 @@
|
||||
+// frontend/lib/api.js
|
||||
+const API_BASE = process.env.NEXT_PUBLIC_API_BASE?.replace(/\/$/, "") || "";
|
||||
+
|
||||
+function getToken() {
|
||||
+ if (typeof window === "undefined") return null;
|
||||
+ return localStorage.getItem("dms.token") || sessionStorage.getItem("dms.token");
|
||||
+}
|
||||
+
|
||||
+export async function apiFetch(path, options = {}) {
|
||||
+ const token = getToken();
|
||||
+ const headers = new Headers(options.headers || {});
|
||||
+ headers.set("Accept", "application/json");
|
||||
+ if (!headers.has("Content-Type")) headers.set("Content-Type", "application/json");
|
||||
+ if (token) headers.set("Authorization", `Bearer ${token}`);
|
||||
+
|
||||
+ const res = await fetch(`${API_BASE}${path}`, { ...options, headers, cache: "no-store" });
|
||||
+
|
||||
+ if (res.status === 401) {
|
||||
+ const refresh =
|
||||
+ localStorage.getItem("dms.refresh_token") || sessionStorage.getItem("dms.refresh_token");
|
||||
+ if (refresh) {
|
||||
+ const r = await fetch(`${API_BASE}/api/auth/refresh`, {
|
||||
+ method: "POST",
|
||||
+ headers: { Authorization: `Bearer ${refresh}` },
|
||||
+ });
|
||||
+ if (r.ok) {
|
||||
+ const { token: newToken } = await r.json();
|
||||
+ const store = localStorage.getItem("dms.refresh_token") ? localStorage : sessionStorage;
|
||||
+ store.setItem("dms.token", newToken);
|
||||
+ const headers2 = new Headers(headers);
|
||||
+ headers2.set("Authorization", `Bearer ${newToken}`);
|
||||
+ return fetch(`${API_BASE}${path}`, { ...options, headers: headers2, cache: "no-store" });
|
||||
+ }
|
||||
+ }
|
||||
+ }
|
||||
+ return res;
|
||||
+}
|
||||
diff --git a/frontend/middleware.ts b/frontend/middleware.ts
|
||||
index aaaaaaa..bbbbbbb 100644
|
||||
--- a/frontend/middleware.ts
|
||||
+++ b/frontend/middleware.ts
|
||||
@@ -1,15 +1,14 @@
|
||||
-import { NextResponse } from "next/server";
|
||||
-import type { NextRequest } from "next/server";
|
||||
-
|
||||
-// เดิม: ตรวจคุกกี้แล้ว redirect /dashboard
|
||||
-export function middleware(req: NextRequest) {
|
||||
- // ... logic เดิมที่ใช้คุกกี้
|
||||
- return NextResponse.next();
|
||||
-}
|
||||
-
|
||||
-export const config = {
|
||||
- matcher: ["/(protected/:path*)","/dashboard","/users/:path*","/api/:path*"],
|
||||
-};
|
||||
+import { NextResponse } from "next/server";
|
||||
+// ✅ ไม่บล็อกเพจอีกต่อไป (Bearer อยู่ใน storage ฝั่ง client)
|
||||
+export function middleware() {
|
||||
+ return NextResponse.next();
|
||||
+}
|
||||
+// จำกัดให้ทำงานเฉพาะ /api ถ้าต้องการใช้ในอนาคต (ตอนนี้ผ่านเฉย ๆ)
|
||||
+export const config = { matcher: ["/api/:path*"] };
|
||||
diff --git a/frontend/app/(protected)/dashboard/page.jsx b/frontend/app/(protected)/dashboard/page.jsx
|
||||
new file mode 100644
|
||||
index 0000000..ccccccc
|
||||
--- /dev/null
|
||||
+++ b/frontend/app/(protected)/dashboard/page.jsx
|
||||
@@ -0,0 +1,11 @@
|
||||
+"use client";
|
||||
+export default function DashboardPage() {
|
||||
+ return (
|
||||
+ <main className="p-6">
|
||||
+ <h1 className="text-2xl font-semibold text-sky-800">Dashboard</h1>
|
||||
+ <p className="text-slate-600 mt-2">
|
||||
+ ยินดีต้อนรับสู่ DMS
|
||||
+ </p>
|
||||
+ </main>
|
||||
+ );
|
||||
+}
|
||||
|
||||
Reference in New Issue
Block a user