Files
places/src/lib/db/auth.ts
2026-05-20 18:08:37 +07:00

169 lines
4.6 KiB
TypeScript

import "server-only";
import { randomBytes } from "node:crypto";
import bcrypt from "bcryptjs";
import { cookies } from "next/headers";
import { eq } from "drizzle-orm";
import { db } from "./client";
import { sessions, users } from "./schema";
import type { User } from "@/lib/types";
export const SESSION_COOKIE = "places_session";
// Best-effort persistence — Chrome/Safari now cap cookie lifetime to ~400d
// regardless of what we set. The DB row has no expiry (expires_at = NULL),
// so re-login isn't required even if the cookie eventually gets pruned.
const COOKIE_MAX_AGE_SECONDS = 60 * 60 * 24 * 365 * 10; // 10 years
function newToken(): string {
return randomBytes(32).toString("base64url");
}
function deriveInitials(name: string): string {
const parts = name.trim().split(/\s+/);
if (parts.length >= 2) {
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
}
return parts[0].slice(0, 2).toUpperCase();
}
export type AuthResult =
| { ok: true; user: User }
| { ok: false; error: string };
export async function registerUser(
email: string,
password: string,
name: string,
): Promise<AuthResult> {
const cleanEmail = email.trim().toLowerCase();
const cleanName = name.trim();
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(cleanEmail)) {
return { ok: false, error: "Email không hợp lệ" };
}
if (password.length < 8) {
return { ok: false, error: "Mật khẩu cần ít nhất 8 ký tự" };
}
if (cleanName.length < 1) {
return { ok: false, error: "Vui lòng nhập tên" };
}
const existing = await db
.select({ id: users.id })
.from(users)
.where(eq(users.email, cleanEmail))
.limit(1);
if (existing.length) {
return { ok: false, error: "Email đã được đăng ký" };
}
const hash = await bcrypt.hash(password, 10);
const initials = deriveInitials(cleanName);
const [inserted] = await db
.insert(users)
.values({
email: cleanEmail,
passwordHash: hash,
name: cleanName,
initials,
})
.returning({
id: users.id,
email: users.email,
name: users.name,
initials: users.initials,
avatarUrl: users.avatarUrl,
color: users.color,
});
await createSessionCookie(inserted.id);
return {
ok: true,
user: {
id: inserted.id,
email: inserted.email,
name: inserted.name,
initials: inserted.initials,
avatar_url: inserted.avatarUrl,
color: inserted.color ?? undefined,
},
};
}
export async function loginUser(
email: string,
password: string,
): Promise<AuthResult> {
const cleanEmail = email.trim().toLowerCase();
const [row] = await db
.select()
.from(users)
.where(eq(users.email, cleanEmail))
.limit(1);
if (!row) return { ok: false, error: "Email hoặc mật khẩu không đúng" };
const ok = await bcrypt.compare(password, row.passwordHash);
if (!ok) return { ok: false, error: "Email hoặc mật khẩu không đúng" };
await createSessionCookie(row.id);
return {
ok: true,
user: {
id: row.id,
email: row.email,
name: row.name,
initials: row.initials,
avatar_url: row.avatarUrl,
color: row.color ?? undefined,
},
};
}
async function createSessionCookie(userId: number): Promise<void> {
const token = newToken();
await db.insert(sessions).values({ id: token, userId, expiresAt: null });
const c = await cookies();
c.set(SESSION_COOKIE, token, {
httpOnly: true,
sameSite: "lax",
secure: process.env.NODE_ENV === "production",
path: "/",
maxAge: COOKIE_MAX_AGE_SECONDS,
});
}
export async function getCurrentUserId(): Promise<number | null> {
const c = await cookies();
const token = c.get(SESSION_COOKIE)?.value;
if (!token) return null;
const [row] = await db
.select({ userId: sessions.userId, expiresAt: sessions.expiresAt })
.from(sessions)
.where(eq(sessions.id, token))
.limit(1);
if (!row) return null;
// expiresAt = NULL means the session never expires. Legacy rows with a
// timestamp value still respect that expiry.
if (row.expiresAt && row.expiresAt.getTime() < Date.now()) {
await db.delete(sessions).where(eq(sessions.id, token));
return null;
}
return row.userId;
}
export async function logout(): Promise<void> {
const c = await cookies();
const token = c.get(SESSION_COOKIE)?.value;
if (token) {
await db.delete(sessions).where(eq(sessions.id, token));
}
c.delete(SESSION_COOKIE);
}
export async function requireUserId(): Promise<number> {
const id = await getCurrentUserId();
if (!id) throw new Error("not authenticated");
return id;
}