FileEditViewTerminal
IT Issue Bootcamp - Visual Studio Code
day-5-hour-2.mdx

Day 5 / Hour 2

Login, Logout, and Protected Pages

Prepared accounts, login form, logout action, requireUser, and protected routes.

60 minutes
Learners can log in and access a protected page.

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 → ตรวจผู้ใช้ → ใช้งาน → Logout

Slide 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 หากยังไม่มี

ลำดับการทำงาน

  1. อ่าน Email และ Password จาก formData
  2. signInWithPassword() ส่งข้อมูลไปให้ Supabase Auth ตรวจสอบ
  3. ถ้าข้อมูลไม่ถูกต้อง ให้กลับหน้า Login พร้อม Error
  4. ถ้าสำเร็จ ให้รีเฟรช 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 Action
  • showError บอกว่าต้องแสดงข้อความ 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");
}
  1. signOut() ยกเลิก Session ปัจจุบัน
  2. revalidatePath() ให้ Layout อ่านสถานะใหม่
  3. 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

  1. เปิด /issues/new ขณะที่ยังไม่ Login → ต้องไป /login
  2. กรอก Password ผิด → ต้องเห็นข้อความแจ้ง
  3. Refresh หน้าอีกครั้ง → ข้อความต้องหาย
  4. Login ด้วยบัญชีที่ถูกต้อง → ต้องไป /issues
  5. Navigation ต้องแสดง New Issue, Email และ Logout
  6. เปิด /issues/new และสร้าง Issue → ต้องบันทึกได้
  7. กด Logout → ต้องกลับ /login
  8. เปิด /issues/new อีกครั้ง → ต้องกลับ /login

ถ้าผลไม่ตรง

อาการจุดที่ควรตรวจ
Login ไม่ผ่านบัญชี, Password, Confirmed Email และข้อความใน Terminal
Login แล้ว Navigation ไม่เปลี่ยนrevalidatePath("/", "layout")
เปิด /issues/new ได้โดยไม่ Loginawait requireUser() ใน Page
ส่งฟอร์มโดยไม่ Login ได้await requireUser() ใน Server Action

Slide 13: Authentication Flow พร้อมใช้งาน

Login → Session Cookie → Current User → Protected Page/Action → Logout

Hour 3 จะเพิ่ม profiles, Role, เจ้าของ Issue และ RLS เพื่อให้ Database ตรวจ Authorization จริง


อ้างอิงสำหรับผู้สอน