導入 Auth.js(NextAuth v5)並以 Drizzle Adapter 連接 PostgreSQL,採用「資料庫 Session」策略,透過 Middleware 保護路由;同步擴充資料庫 Schema、整理程式分層、調整首頁與測試。
漸漸得我開始習慣在 ChatGPT、Codex、Cursor 之間來回切換,試圖利用他們各自的能力最大化我在時間內能完成的內容,但結果還是常常不盡人意,Cursor 看似最多功能,可即便引入了官方文件他還是會寫出過時的代碼,Codex CLI跟我說他無法上網導致他無法看到最新版本的文件,Codex in IDE 大概介在中間,是我用起來最順的工具,ChatGPT 則是目前用來前期討論,畢竟他能把對話打包成專案,讓他跟我溝通的時候比較有延續性。
另外開始感受到任務切分的重要性,今天一次性要求太多內容,雖然已經跟 ChatGPT 討論過並且讓他產生完整的 prompt 給 Codex 使用,但還一次過多內容還是讓 Codex 寫出不符合我需求的內容,之後應該要改變方式明確任務拆分跟參考資料,測試看看是否能改善工作範圍變大時的產出正確性。
sessions
表並由 Adapter 管理callbacks.authorized
控制放行;預設保護站內路由users
欄位(emailVerified
, image
),新增 NextAuth 所需表app/api/auth/[...nextauth]
路由,導出 Auth.js handlersSession.user.id
(型別安全地取得使用者 ID)新增依賴:
{
"dependencies": {
"next-auth": "^5.0.0-beta.29",
"@auth/drizzle-adapter": "^1.9.2"
}
}
保留 Drizzle 指令:db:generate
/ db:migrate
/ db:seed
/ db:studio
/ db:push
新增環境變數(.env
/ .env.example
):
AUTH_SECRET=...
AUTH_URL=http://localhost:3000
AUTH_TRUST_HOST=false
擴充 users
與新增 NextAuth adapter 所需表:
src/lib/db/schema.ts
export const users = pgTable(
'users',
{
id: uuid('id').defaultRandom().primaryKey(),
email: varchar('email', { length: 255 }).notNull().unique(),
emailVerified: timestamp('email_verified', { mode: 'date', withTimezone: true }),
password: varchar('password', { length: 255 }).notNull(),
name: varchar('name', { length: 255 }).notNull(),
image: varchar('image', { length: 255 }),
createdAt: timestamp('created_at', { mode: 'date', withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { mode: 'date', withTimezone: true }).defaultNow().notNull(),
},
(table) => [index('users_created_at_id_idx').on(table.createdAt, table.id)]
);
// NextAuth adapter: 儲存第三方登入帳號資訊
export const accounts = pgTable('accounts', {
/* provider/composite PK 等欄位 */
});
// NextAuth adapter: 儲存登入 session
export const sessions = pgTable('sessions', {
/* sessionToken / userId / expires */
});
// NextAuth adapter: 信箱登入/驗證流程用的 token
export const verificationTokens = pgTable('verification_tokens', {
/* identifier/token/expiry */
});
// NextAuth adapter: WebAuthn 等實體驗證器
export const authenticators = pgTable('authenticators', {
/* credentialID / userId 等欄位 */
});
產生的 migration(節錄):
scripts/db/migrations/0003_mean_vanisher.sql
ALTER TABLE "users" ADD COLUMN "email_verified" timestamptz;
ALTER TABLE "users" ADD COLUMN "image" varchar(255);
CREATE TABLE "accounts" (...);
CREATE TABLE "sessions" (...);
CREATE TABLE "verification_tokens" (...);
CREATE TABLE "authenticators" (...);
執行:
npm run db:generate
npm run db:migrate
Auth.js 設定採用 Drizzle Adapter、Credentials Provider、資料庫 Session,並於 authorized
控制放行:
src/server/auth/index.ts
export const authConfig = {
adapter: DrizzleAdapter(db, {
usersTable: users,
accountsTable: accounts,
sessionsTable: sessions,
verificationTokensTable: verificationTokens,
authenticatorsTable: authenticators,
}),
session: { strategy: 'database' },
providers: [
Credentials({
async authorize(input) {
const { email, password } = input ?? {};
if (typeof email !== 'string' || typeof password !== 'string') return null;
const user = await findByEmail(email);
if (!user?.password) return null;
const ok = await bcrypt.compare(password, user.password);
if (!ok) return null;
const { password: _pw, ...safe } = user;
return safe;
},
}),
],
pages: { signIn: '/login' },
callbacks: {
authorized({ request, auth }) {
const { pathname } = request.nextUrl;
if (pathname.startsWith('/login') || pathname.startsWith('/signup')) return true;
if (pathname.startsWith('/api/auth')) return true;
if (pathname.startsWith('/_next')) return true;
if (pathname === '/favicon.ico' || pathname.includes('.')) return true;
return !!auth?.user;
},
async session({ session, user }) {
if (session.user && user) session.user.id = user.id;
return session;
},
},
secret: process.env.AUTH_SECRET,
} satisfies NextAuthConfig;
export const { handlers, auth } = NextAuth(authConfig);
Middleware 導出與篩選:
middleware.ts
export { auth as middleware } from '@/server/auth';
export const config = { matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'] };
Auth.js 路由:
src/app/api/auth/[...nextauth]/route.ts
import { handlers } from '@/server/auth';
export const { GET, POST } = handlers;
findByEmail
,而非直接 db.query.users
。
src/server/auth/index.ts
→ src/server/repos/usersRepo.ts:11
src/server/repos/usersRepo.ts:21
UsersList
:src/app/_components/users-list.tsx
src/app/page.tsx
types/next-auth.d.ts
(提供 session.user.id
)users
欄位:tests/api.users.get.test.ts
npx tsc --noEmit
、npm run lint
失敗的 prompt:
技術棧與原則
Next.js App Router、Auth.js v5(NextAuth 新版)、Drizzle(Postgres)、shadcn/ui、Tailwind。
路由與資料夾結構遵循現有專案規範(src/app、src/server、src/lib/db 等)。
Session 採用 Auth.js 預設 JWT(session.strategy = 'jwt')。
資料庫與 Adapter
保留既有 users 資料表結構(password 欄位存雜湊值,非明碼)。
導入 @auth/drizzle-adapter 以持久化使用者與必要表;新增 Drizzle schema 與遷移建立:accounts、sessions、verificationTokens(JWT session 不用 sessions,但建立不衝突)。
若需,對 Adapter 進行自訂 schema 對應,以沿用現有 users 表(至少需 id、email、name)。
環境變數(Auth.js)
AUTH_SECRET:簽名/加密 JWT 與 token 的金鑰(強隨機、正式環境必填)。
AUTH_URL:對外 Base URL(例如 http://localhost:3000、生產網域),用於產生回呼與重導 URL。
AUTH_TRUST_HOST:在反向代理情境允許信任 X-Forwarded-* 推斷 Host/協定(設定 true 可解代理問題;若已正確設 AUTH_URL 通常可不開)。
Auth.js 初始化與 Middleware(Edge 兼容)
新增 auth.config.ts:共用設定(Provider、callbacks 等),不含 adapter。
新增根層 auth.ts:NextAuth({ adapter: DrizzleAdapter(db), session: { strategy: 'jwt' }, ...authConfig }) 並輸出 { handlers, auth, signIn, signOut }。
新增 app/api/auth/[...nextauth]/route.ts:re-export handlers 的 GET/POST。
新增 middleware.ts:以 NextAuth(authConfig) 的 auth 實例在中介層使用(不含 adapter),自訂 gating 邏輯(見下方),而非單純 export { auth as middleware }。
路由保護規則(middleware)
放行:/login、/api/auth/、/_next/、/favicon.ico、其他帶副檔名的靜態資源。
其他路徑:無有效 JWT session → redirect /login。
已登入造訪 /login → redirect /。
注意:middleware 僅做 gating 與 keep-alive,不查 DB(沿用不含 adapter 的 authConfig)。
Credentials Provider(Email/Password 登入)
在 auth.ts 設定 Credentials provider。
authorize():以 email 查詢使用者,使用 bcryptjs.compare() 驗證密碼;正確回傳使用者(至少 id、email、name),錯誤回傳 null(語意等同舊 CredentialsSignin)。
登入僅驗證帳密正確,不檢查密碼複雜度。
註冊 API:/api/auth/signup
使用 zod 驗證:
長度 8–72
至少 1 數字 [0-9]、1 小寫 [a-z]、1 大寫 [A-Z]、1 特殊符號 [^A-Za-z0-9]
確認密碼一致
流程:檢查 email 重複 → bcryptjs.hash() 儲存 → 建立使用者(不自動登入)。
回應:成功 201;重複 409;驗證錯誤 400(各錯誤依下述 error 模型回傳)。
成功後前端導回 /login。
錯誤模型(域錯誤碼 + HTTP 狀態)與工具
更新 src/server/errors.ts 為可延展模型:
ErrorCode:如 VALIDATION_ERROR、EMAIL_TAKEN、INVALID_CREDENTIALS、INTERNAL_ERROR。
ErrorDescriptor[code] = { httpStatus, message }。
errorResponse(code, { details, issues }) 與 ok(data, status?) 工具,統一 API 回應格式。
目標回應(前端穩定結構):
{ code: 'EMAIL_TAKEN', message: 'Email 已被註冊', details?: any, issues?: { path: string[], message: string }[] }。
/login UI(shadcn/ui,仿 IG)
src/app/login/page.tsx 為 RSC;_components/AuthForm.tsx 為 client("use client")。
結構:Tabs(登入 | 註冊)
登入:Email、密碼、登入按鈕
註冊:名稱、Email、密碼、確認密碼
密碼規則清單即時打勾顯示、未通過禁送出
欄位錯誤訊息 + 全域 Alert;提交時按鈕 loading
RWD:
Mobile:卡片寬 92%,置中
Tablet:卡片固定 420–480px
Desktop:置中顯示,首頁留白即可
元件:Tabs、Form(react-hook-form + zod resolver)、Input、Label、Button、Card、Alert。
使用 shadcn CLI 新增所需元件;遵循專案命名與匯入規則(@/ alias)。
清理舊範例
刪除 users 查詢的頁面與 API、相關 server/repo 與測試:
src/app/api/users/route.ts
src/app/_components/users-list.tsx
src/server/users.ts、src/server/repos/usersRepo.ts(若已不再使用)
tests/api.users.get.test.ts、tests/api.users.post.test.ts、tests/service.users.test.ts
測試(Vitest + Playwright)
Vitest(整合/單元):
/api/auth/signup:成功 201、重複 email 409、密碼規則不符 400、二次密碼不一致 400
Credentials authorize():正確帳密成功;錯誤密碼回 null(語意 = Invalid Credentials)
密碼 schema:缺數字/小寫/大寫/特殊/長度不足/超 72 → 失敗;全部符合 → 成功
middleware:未登入訪問 / → 302 /login;未登入訪問 /login → 放行;已登入訪問 /login → /
Playwright(E2E):
註冊:填表 → 成功 → 導回登入
登入:新帳號登入 → 首頁顯示登入狀態 → 刷新仍保持登入
錯誤登入:顯示錯誤訊息
登出:點登出 → 回到登入卡
middleware:未登入直訪 / → 導到 /login;已登入直訪 /login → 導到 /
RWD:手機/平板/桌機下排版不破版
測試環境建議使用 SQLite 或測試專用 DB,.env.test 與 .env.local 區隔。
相依與 README 調整
依賴:
正式:next-auth、@auth/drizzle-adapter、zod、bcryptjs、react-hook-form、@hookform/resolvers、lucide-react(如需 icon)
已有:drizzle-orm、pg、dayjs、tailwindcss 等
README 修正:
使用 bcryptjs(非 bcrypt),新增三個 AUTH 變數說明與設定範例
加入 shadcn CLI 使用方式與所需元件清單
DB 工作流(db:generate → review SQL → db:migrate → db:seed)
測試指令與環境注意事項
開發與交付項目(產出清單)
新增:auth.config.ts、auth.ts、app/api/auth/[...nextauth]/route.ts、app/api/auth/signup/route.ts、middleware.ts、app/login/page.tsx、app/login/_components/AuthForm.tsx、src/server/validation/auth.ts(zod)
更新:src/server/errors.ts(域錯誤碼模型與 helper)、README.md(依賴/ENV/CLI)
DB:新增 adapter 所需表的 Drizzle schema 與遷移;保留 users(password 雜湊)
移除:舊 users 範例頁/API/測試與未使用的 server/repo 模組
測試:Vitest 覆蓋 API/authorize/schema/middleware、Playwright 覆蓋 e2e 與 RWD
驗收標準(關鍵檢核)
/api/auth/signup 依規格回 201/409/400 並使用統一錯誤格式
Credentials 登入以 bcryptjs.compare() 驗證成功/失敗
middleware 符合放行/阻擋/重導規則
/login UI 行為與 RWD 符合
移除舊 users 範例後,測試全綠(Vitest + Playwright)
README 與依賴一致(含 bcryptjs); .env.local / .env.test 設定齊全
備註
密碼複雜度限制作用於「使用者輸入明碼」(8–72),雜湊字串(約 60)存入 users.password,varchar(255) 足夠。
之後若加入節流/速率限制、i18n 化錯誤訊息、OAuth provider,可在此基礎擴充。