Login, Logout, and Protected Pages
Day 5 - ชั่วโมงที่ 2: Login และป้องกันส่วนที่ต้องมีผู้ใช้
ชั่วโมงนี้เราจะนำ Supabase SSR จาก Hour 1 มาใช้ Login จริง แล้วตรวจ Session ก่อนเปิดหน้าและเรียก Server Action ที่ต้องป้องกัน
app/actions.ts เพิ่ม Login และ Logout
components/LoginForm.tsx สร้างใหม่
app/login/page.tsx สร้างใหม่
lib/auth.ts สร้างใหม่
app/issues/new/page.tsx แก้บางส่วน
components/AppNav.tsx สร้างใหม่
app/layout.tsx แก้บางส่วนSlide 1: จาก Session Setup สู่ Login จริง
Hour 1 เตรียม Server Client และ Proxy ให้ Supabase อ่านและอัปเดต Session Cookie ได้แล้ว แต่ระบบยังไม่มีหน้าสำหรับ Login
Hour นี้เราจะต่อ Flow ให้ครบ:
Login → Supabase ตรวจบัญชี → เก็บ Session → ตรวจผู้ใช้ → ใช้งาน → LogoutSlide 2: เตรียมบัญชีสำหรับทดลอง
user@example.com
admin@example.com- กำหนด Password ที่จำได้สำหรับการทดลอง
- เปิด Auto Confirm User หรือตรวจให้ Email เป็น Confirmed
- ทั้งสองบัญชียัง Login ได้เหมือนกัน ส่วน Role
USERและADMINจะเพิ่มใน Hour 3
ทำไมใช้บัญชีที่เตรียมไว้ล่วงหน้า
Issue Tracker เป็นระบบภายในองค์กร บัญชีผู้ใช้จึงควรถูกสร้างและดูแลโดยผู้ดูแลระบบ ไม่เปิดให้บุคคลทั่วไปสมัครใช้งานเอง
วิธีนี้ยังช่วยให้เราโฟกัสเรื่อง Login, Session และสิทธิ์ของ USER กับ ADMIN ได้โดยไม่ต้องเพิ่มขั้นตอนยืนยัน Email หรืออนุมัติบัญชี
Slide 3: Login Flow แบ่งหน้าที่อย่างไร
flowchart LR
A["LoginForm รับ Email และ Password"] --> B["signInAction ทำงานบน Server"]
B --> C["Supabase Auth ตรวจบัญชี"]
C -->|"สำเร็จ"| D["เก็บ Session และไป /issues"]
C -->|"ไม่สำเร็จ"| E["กลับ /login พร้อมข้อความแจ้ง"]LoginFormรับข้อมูลจากผู้ใช้signInActionส่งข้อมูลไปตรวจผ่าน Supabase Server Client- Supabase SSR บันทึก Session ลง Cookie เมื่อ Login สำเร็จ
Slide 4: เพิ่ม Server Action สำหรับ Login
signInAction() รับ FormData จากฟอร์ม แล้วใช้ Supabase Auth ตรวจ Email และ Password
import { createClient } from "@/lib/supabase/server";
export async function signInAction(formData: FormData) {
const email = String(formData.get("email") ?? "").trim();
const password = String(formData.get("password") ?? "");
if (!email || !password) {
redirect("/login?error=invalid-credentials");
}
const supabase = await createClient();
const { error } = await supabase.auth.signInWithPassword({ email, password });
if (error) {
console.error("Login failed:", error.message);
redirect("/login?error=invalid-credentials");
}
revalidatePath("/", "layout");
redirect("/issues");
}redirect และ revalidatePath มีอยู่ในไฟล์จาก Day 4 แล้ว จึงเพิ่มเฉพาะ Import ของ createClient หากยังไม่มี
ลำดับการทำงาน
- อ่าน Email และ Password จาก
formData signInWithPassword()ส่งข้อมูลไปให้ Supabase Auth ตรวจสอบ- ถ้าข้อมูลไม่ถูกต้อง ให้กลับหน้า Login พร้อม Error
- ถ้าสำเร็จ ให้รีเฟรช Layout แล้วไปหน้า
/issues
revalidatePath("/", "layout") สั่งให้ Layout อ่าน Session ใหม่ เพื่อให้ Navigation เปลี่ยนตามสถานะ Login
Slide 5: สร้าง Login Form
"use client";
import { useEffect } from "react";
import { signInAction } from "@/app/actions";
type LoginFormProps = {
showError: boolean;
};
export function LoginForm({ showError }: LoginFormProps) {
useEffect(() => {
if (showError) {
window.history.replaceState(null, "", "/login");
}
}, [showError]);
return (
<>
{showError && (
<p className="mt-4 text-sm font-medium text-red-700">
อีเมลหรือรหัสผ่านไม่ถูกต้อง
</p>
)}
<form action={signInAction} className="mt-6 grid gap-4">
<label className="grid gap-2 text-sm font-semibold text-slate-800">
Email
<input
name="email"
type="email"
autoComplete="email"
required
className="rounded-md border border-slate-300 px-3 py-2"
/>
</label>
<label className="grid gap-2 text-sm font-semibold text-slate-800">
Password
<input
name="password"
type="password"
autoComplete="current-password"
required
className="rounded-md border border-slate-300 px-3 py-2"
/>
</label>
<button className="rounded-md bg-teal-700 px-4 py-3 text-sm font-bold text-white">
เข้าสู่ระบบ
</button>
</form>
</>
);
}action={signInAction}ส่งค่าจาก Input เป็นFormDataไปยัง Server ActionshowErrorบอกว่าต้องแสดงข้อความ Login ไม่สำเร็จหรือไม่replaceState()ลบ Error ออกจาก URL โดยไม่ Reload หน้า เมื่อ Refresh ครั้งถัดไปข้อความจึงหาย
Slide 6: สร้างหน้า /login
import { LoginForm } from "@/components/LoginForm";
type LoginPageProps = {
searchParams: Promise<{ error?: string }>;
};
export default async function LoginPage({ searchParams }: LoginPageProps) {
const { error } = await searchParams;
return (
<main className="mx-auto max-w-md px-6 py-12">
<section className="rounded-lg border border-slate-200 bg-white p-6">
<h1 className="text-2xl font-bold text-slate-950">เข้าสู่ระบบ</h1>
<p className="mt-2 text-sm text-slate-600">
ใช้บัญชีที่เตรียมไว้เพื่อแจ้งและติดตามปัญหา
</p>
<LoginForm showError={error === "invalid-credentials"} />
</section>
</main>
);
}searchParams อ่านค่าจาก URL เช่น /login?error=invalid-credentials ใน Next.js เวอร์ชันนี้ค่านี้เป็น Promise จึงต้องใช้ await ก่อน
Slide 7: สร้าง Helper สำหรับอ่านผู้ใช้
หลัง Login แล้ว Session อยู่ใน Cookie แต่ Page และ Action ยังต้องตรวจว่า Session นี้เป็นของผู้ใช้คนใด
import { redirect } from "next/navigation";
import { createClient } from "@/lib/supabase/server";
export async function getCurrentUser() {
const supabase = await createClient();
const { data, error } = await supabase.auth.getUser();
if (error || !data.user) {
return null;
}
return data.user;
}
export async function requireUser() {
const user = await getCurrentUser();
if (!user) {
redirect("/login");
}
return user;
}| Helper | ใช้เมื่อ | ผลลัพธ์เมื่อยังไม่ Login |
|---|---|---|
getCurrentUser() | ต้องการรู้ว่ามีใคร Login อยู่หรือไม่ | คืน null |
requireUser() | ส่วนนั้นบังคับว่าต้อง Login | ส่งไป /login |
getUser() ตรวจ Session กับ Supabase Auth แล้วคืนข้อมูล เช่น id และ email ของผู้ใช้
Slide 8: ป้องกันทั้ง Page และ Server Action
การป้องกัน Page ทำให้คนที่ยังไม่ Login เปิดฟอร์มไม่ได้ แต่ Server Action ต้องตรวจซ้ำ เพราะอาจถูกเรียกโดยตรงโดยไม่ผ่านหน้าเว็บ
Protected Page → ป้องกันการเปิดหน้าผ่าน URL
Protected Action → ป้องกันการส่ง Request เข้ามาโดยตรงจุดที่ 1: แก้ app/issues/new/page.tsx
เก็บ JSX เดิมทั้งหมดไว้ แล้วเพิ่ม Import กับ await requireUser() ก่อน return
import { IssueForm } from "@/components/IssueForm";import { requireUser } from "@/lib/auth"; export default async function NewIssuePage() { await requireUser(); return ( // เก็บ JSX เดิมไว้ );}
จุดที่ 2: แก้ createIssueAction() ใน app/actions.ts
เพิ่ม Import แล้วตรวจผู้ใช้เป็นบรรทัดแรกของ Action โดยเก็บ Code Parse, Validate และ Create เดิมไว้
import { requireUser } from "@/lib/auth"; export async function createIssueAction(formData: FormData) { await requireUser(); const input = parseIssueInput(formData); // เก็บโค้ดเดิมด้านล่างไว้}
Navigation และ Protected Page ช่วยควบคุมสิ่งที่ผู้ใช้เห็น ส่วน requireUser() ใน Action ป้องกันการเขียนข้อมูลที่ฝั่ง Server
Slide 9: เพิ่ม Server Action สำหรับ Logout
Login สร้าง Session ส่วน Logout ยกเลิก Session เพื่อให้ Request ถัดไปไม่ถูกมองว่าเป็นผู้ใช้คนเดิม
export async function signOutAction() {
const supabase = await createClient();
const { error } = await supabase.auth.signOut();
if (error) {
redirect("/issues?error=logout-failed");
}
revalidatePath("/", "layout");
redirect("/login");
}signOut()ยกเลิก Session ปัจจุบันrevalidatePath()ให้ Layout อ่านสถานะใหม่redirect()พากลับหน้า Login
Slide 10: สร้าง Navigation ตามสถานะ Login
Navigation ใช้ getCurrentUser() เพราะต้องแสดงได้ทั้งตอนที่มีและไม่มีผู้ใช้
import Link from "next/link";
import { signOutAction } from "@/app/actions";
import { getCurrentUser } from "@/lib/auth";
export async function AppNav() {
const user = await getCurrentUser();
return (
<nav className="flex items-center justify-between border-b px-6 py-3">
<Link href="/issues" className="font-bold">
Issue Tracker
</Link>
<div className="flex items-center gap-4 text-sm">
{user ? (
<>
<Link href="/issues/new" className="font-semibold">
New Issue
</Link>
<span>{user.email}</span>
<form action={signOutAction}>
<button className="font-semibold text-teal-700">
Logout
</button>
</form>
</>
) : (
<Link href="/login" className="font-semibold text-teal-700">
Login
</Link>
)}
</div>
</nav>
);
}- ถ้ามี
userจะแสดง New Issue, Email และ Logout - ถ้าไม่มี
userจะแสดง Login <form action={signOutAction}>เรียก Server Action ได้โดยไม่ต้องสร้าง Event Handler
Slide 11: แสดง Navigation ในทุกหน้า
RootLayout ครอบทุก Route จึงเป็นตำแหน่งที่เหมาะสำหรับ Navigation ที่ใช้ร่วมกันทั้งระบบ
import Link from "next/link";import { AppNav } from "@/components/AppNav"; <html lang="th" className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}> <body className="min-h-full flex flex-col"> <nav className="border-b border-slate-200 bg-white" aria-label="เมนูหลัก"> <div className="mx-auto flex max-w-5xl flex-wrap gap-2 px-6 py-3"> <Link href="/">หน้าแรก</Link> <Link href="/issues">รายการปัญหา</Link> <Link href="/issues/new">แจ้งปัญหาใหม่</Link> </div> </nav> <AppNav /> {children} </body></html>
- ลบ
import Link from "next/link"เพราะ Layout ไม่ได้สร้าง Link เองแล้ว - ลบ
<nav>...</nav>ชุดเดิมทั้งก้อน เพื่อไม่ให้มี Navigation ซ้ำสองชุด - เพิ่ม Import ของ
AppNavและวาง<AppNav />ก่อน{children} - เก็บ
<html>,<body>, Font Classes และ{children}เดิมไว้
อย่าเรียก requireUser() ใน Root Layout เพราะหน้า /login ก็ใช้ Layout นี้ หากบังคับ Login ที่ Layout จะทำให้หน้าสาธารณะเปิดไม่ได้
Slide 12: ทดสอบ Authentication Flow
- เปิด
/issues/newขณะที่ยังไม่ Login → ต้องไป/login - กรอก Password ผิด → ต้องเห็นข้อความแจ้ง
- Refresh หน้าอีกครั้ง → ข้อความต้องหาย
- Login ด้วยบัญชีที่ถูกต้อง → ต้องไป
/issues - Navigation ต้องแสดง New Issue, Email และ Logout
- เปิด
/issues/newและสร้าง Issue → ต้องบันทึกได้ - กด Logout → ต้องกลับ
/login - เปิด
/issues/newอีกครั้ง → ต้องกลับ/login
ถ้าผลไม่ตรง
| อาการ | จุดที่ควรตรวจ |
|---|---|
| Login ไม่ผ่าน | บัญชี, Password, Confirmed Email และข้อความใน Terminal |
| Login แล้ว Navigation ไม่เปลี่ยน | revalidatePath("/", "layout") |
เปิด /issues/new ได้โดยไม่ Login | await requireUser() ใน Page |
| ส่งฟอร์มโดยไม่ Login ได้ | await requireUser() ใน Server Action |
Slide 13: Authentication Flow พร้อมใช้งาน
Login → Session Cookie → Current User → Protected Page/Action → LogoutHour 3 จะเพิ่ม profiles, Role, เจ้าของ Issue และ RLS เพื่อให้ Database ตรวจ Authorization จริง