| import { Router } from "express"; |
| import bcrypt from "bcryptjs"; |
| import jwt from "jsonwebtoken"; |
| import { db, usersTable, configTable, creditTransactionsTable } from "@workspace/db"; |
| import { eq, count } from "drizzle-orm"; |
|
|
| const router = Router(); |
| const JWT_SECRET = process.env.SESSION_SECRET || "dev-secret-change-me"; |
| const COOKIE_NAME = "sf_auth"; |
| const COOKIE_OPTS = { |
| httpOnly: true, |
| sameSite: "lax" as const, |
| secure: process.env.NODE_ENV === "production", |
| maxAge: 30 * 24 * 60 * 60 * 1000, |
| path: "/", |
| }; |
|
|
| function signToken(userId: number, email: string) { |
| return jwt.sign({ userId, email }, JWT_SECRET, { expiresIn: "30d" }); |
| } |
|
|
| async function isFirstUser(): Promise<boolean> { |
| const [row] = await db.select({ count: count() }).from(usersTable); |
| return Number(row.count) === 0; |
| } |
|
|
| router.post("/signup", async (req, res) => { |
| const { email, password, displayName } = req.body as { |
| email?: string; |
| password?: string; |
| displayName?: string; |
| }; |
|
|
| if (!email || !password) { |
| return res.status(400).json({ error: "Email and password are required" }); |
| } |
| if (password.length < 6) { |
| return res.status(400).json({ error: "Password must be at least 6 characters" }); |
| } |
| if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { |
| return res.status(400).json({ error: "Invalid email format" }); |
| } |
|
|
| const existing = await db.select({ id: usersTable.id }).from(usersTable).where(eq(usersTable.email, email.toLowerCase())).limit(1); |
| if (existing.length > 0) { |
| return res.status(409).json({ error: "Email already registered" }); |
| } |
|
|
| const firstUser = await isFirstUser(); |
| const passwordHash = await bcrypt.hash(password, 12); |
| const [user] = await db.insert(usersTable).values({ |
| email: email.toLowerCase(), |
| passwordHash, |
| displayName: displayName?.trim() || null, |
| isAdmin: firstUser, |
| }).returning({ |
| id: usersTable.id, |
| email: usersTable.email, |
| displayName: usersTable.displayName, |
| isAdmin: usersTable.isAdmin, |
| }); |
|
|
| |
| const signupCreditRow = await db.select({ value: configTable.value }).from(configTable).where(eq(configTable.key, "signup_credits")).limit(1); |
| const signupCredits = Number(signupCreditRow[0]?.value) || 0; |
| if (signupCredits > 0) { |
| await db.update(usersTable).set({ credits: signupCredits }).where(eq(usersTable.id, user.id)); |
| await db.insert(creditTransactionsTable).values({ userId: user.id, amount: signupCredits, type: "signup", description: "新用戶註冊贈點" }); |
| } |
|
|
| const token = signToken(user.id, user.email); |
| res.cookie(COOKIE_NAME, token, COOKIE_OPTS); |
| res.json({ id: user.id, email: user.email, displayName: user.displayName, isAdmin: user.isAdmin, credits: signupCredits }); |
| }); |
|
|
| router.post("/login", async (req, res) => { |
| const { email, password } = req.body as { email?: string; password?: string }; |
|
|
| if (!email || !password) { |
| return res.status(400).json({ error: "Email and password are required" }); |
| } |
|
|
| const [user] = await db.select().from(usersTable).where(eq(usersTable.email, email.toLowerCase())).limit(1); |
| if (!user) { |
| return res.status(401).json({ error: "Invalid email or password" }); |
| } |
|
|
| const valid = await bcrypt.compare(password, user.passwordHash); |
| if (!valid) { |
| return res.status(401).json({ error: "Invalid email or password" }); |
| } |
|
|
| const token = signToken(user.id, user.email); |
| res.cookie(COOKIE_NAME, token, COOKIE_OPTS); |
| res.json({ id: user.id, email: user.email, displayName: user.displayName, isAdmin: user.isAdmin }); |
| }); |
|
|
| router.post("/logout", (_req, res) => { |
| res.clearCookie(COOKIE_NAME, { path: "/" }); |
| res.json({ success: true }); |
| }); |
|
|
| router.get("/me", async (req, res) => { |
| const token = req.cookies?.[COOKIE_NAME]; |
| if (!token) return res.status(401).json({ error: "Not authenticated" }); |
|
|
| try { |
| const payload = jwt.verify(token, JWT_SECRET) as { userId: number; email: string }; |
| const [user] = await db |
| .select({ id: usersTable.id, email: usersTable.email, displayName: usersTable.displayName, isAdmin: usersTable.isAdmin }) |
| .from(usersTable) |
| .where(eq(usersTable.id, payload.userId)) |
| .limit(1); |
| if (!user) { |
| res.clearCookie(COOKIE_NAME, { path: "/" }); |
| return res.status(401).json({ error: "User not found" }); |
| } |
| res.json(user); |
| } catch { |
| res.clearCookie(COOKIE_NAME, { path: "/" }); |
| res.status(401).json({ error: "Session expired" }); |
| } |
| }); |
|
|
| export function requireJwtAuth(req: any, res: any, next: any) { |
| const token = req.cookies?.[COOKIE_NAME]; |
| if (!token) return res.status(401).json({ error: "Unauthorized" }); |
|
|
| try { |
| const payload = jwt.verify(token, JWT_SECRET) as { userId: number; email: string }; |
| req.jwtUserId = payload.userId; |
| req.jwtEmail = payload.email; |
| next(); |
| } catch { |
| res.clearCookie(COOKIE_NAME, { path: "/" }); |
| res.status(401).json({ error: "Session expired" }); |
| } |
| } |
|
|
| export function optionalJwtAuth(req: any, _res: any, next: any) { |
| const token = req.cookies?.[COOKIE_NAME]; |
| if (token) { |
| try { |
| const payload = jwt.verify(token, JWT_SECRET) as { userId: number; email: string }; |
| req.jwtUserId = payload.userId; |
| req.jwtEmail = payload.email; |
| } catch { |
| |
| } |
| } |
| next(); |
| } |
|
|
| export default router; |
|
|