165 lines
4.3 KiB
TypeScript
165 lines
4.3 KiB
TypeScript
import "server-only";
|
|
import { randomBytes } from "node:crypto";
|
|
import bcrypt from "bcryptjs";
|
|
import { cookies } from "next/headers";
|
|
import { and, eq, gt } from "drizzle-orm";
|
|
import { db } from "./client";
|
|
import { sessions, users } from "./schema";
|
|
import type { User } from "@/lib/types";
|
|
|
|
export const SESSION_COOKIE = "places_session";
|
|
const SESSION_DAYS = 30;
|
|
|
|
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();
|
|
const expiresAt = new Date(Date.now() + SESSION_DAYS * 24 * 60 * 60 * 1000);
|
|
await db.insert(sessions).values({ id: token, userId, expiresAt });
|
|
const c = await cookies();
|
|
c.set(SESSION_COOKIE, token, {
|
|
httpOnly: true,
|
|
sameSite: "lax",
|
|
secure: process.env.NODE_ENV === "production",
|
|
path: "/",
|
|
expires: expiresAt,
|
|
});
|
|
}
|
|
|
|
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(and(eq(sessions.id, token), gt(sessions.expiresAt, new Date())))
|
|
.limit(1);
|
|
if (!row) {
|
|
// Clean up an expired/invalid token if present.
|
|
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;
|
|
}
|