feat(frontend): email+password auth, /signin + /signup pages, marketing consolidation, onboarding workspace naming + full data persistence
This commit is contained in:
@@ -15,19 +15,16 @@ const isLocalNextAuth =
|
||||
(process.env.NODE_ENV === "development" && !nextAuthUrl);
|
||||
|
||||
/** Set in .env.local (server + client): one email for local dev bypass. */
|
||||
const devLocalEmail = (process.env.NEXT_PUBLIC_DEV_LOCAL_AUTH_EMAIL ?? "").trim();
|
||||
const devLocalEmail = (
|
||||
process.env.NEXT_PUBLIC_DEV_LOCAL_AUTH_EMAIL ?? ""
|
||||
).trim();
|
||||
const devLocalSecret = (process.env.DEV_LOCAL_AUTH_SECRET ?? "").trim();
|
||||
const devLocalAuthEnabled =
|
||||
process.env.NODE_ENV === "development" && devLocalEmail.length > 0;
|
||||
|
||||
function isLocalhostHost(host: string): boolean {
|
||||
const h = host.split(":")[0]?.toLowerCase() ?? "";
|
||||
return (
|
||||
h === "localhost" ||
|
||||
h === "127.0.0.1" ||
|
||||
h === "[::1]" ||
|
||||
h === "::1"
|
||||
);
|
||||
return h === "localhost" || h === "127.0.0.1" || h === "[::1]" || h === "::1";
|
||||
}
|
||||
|
||||
export const authOptions: NextAuthOptions = {
|
||||
@@ -44,7 +41,8 @@ export const authOptions: NextAuthOptions = {
|
||||
password: { label: "Dev secret", type: "password" },
|
||||
},
|
||||
async authorize(credentials, req) {
|
||||
const headers = (req as { headers?: Headers } | undefined)?.headers;
|
||||
const headers = (req as { headers?: Headers } | undefined)
|
||||
?.headers;
|
||||
const host =
|
||||
headers && typeof headers.get === "function"
|
||||
? (headers.get("host") ?? "")
|
||||
@@ -87,8 +85,8 @@ export const authOptions: NextAuthOptions = {
|
||||
}),
|
||||
],
|
||||
pages: {
|
||||
signIn: "/auth",
|
||||
error: "/auth",
|
||||
signIn: "/signin",
|
||||
error: "/signin",
|
||||
},
|
||||
callbacks: {
|
||||
async session({ session, user }) {
|
||||
@@ -101,7 +99,10 @@ export const authOptions: NextAuthOptions = {
|
||||
if (!user?.email) return true;
|
||||
try {
|
||||
const workspace =
|
||||
user.email.split("@")[0].toLowerCase().replace(/[^a-z0-9]+/g, "-") + "-account";
|
||||
user.email
|
||||
.split("@")[0]
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-") + "-account";
|
||||
const data = JSON.stringify({
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
@@ -112,7 +113,7 @@ export const authOptions: NextAuthOptions = {
|
||||
// Two-step upsert avoids relying on ON CONFLICT expression matching
|
||||
const existing = await query<{ id: string }>(
|
||||
`SELECT id FROM fs_users WHERE data->>'email' = $1 LIMIT 1`,
|
||||
[user.email]
|
||||
[user.email],
|
||||
);
|
||||
let fsUserId: string;
|
||||
if (existing.length === 0) {
|
||||
@@ -120,13 +121,13 @@ export const authOptions: NextAuthOptions = {
|
||||
`INSERT INTO fs_users (id, user_id, data)
|
||||
VALUES (gen_random_uuid()::text, $1, $2::jsonb)
|
||||
RETURNING id`,
|
||||
[user.id, data]
|
||||
[user.id, data],
|
||||
);
|
||||
fsUserId = inserted[0].id;
|
||||
} else {
|
||||
await query(
|
||||
`UPDATE fs_users SET user_id = $1, data = data || $2::jsonb, updated_at = NOW() WHERE id = $3`,
|
||||
[user.id, data, existing[0].id]
|
||||
[user.id, data, existing[0].id],
|
||||
);
|
||||
fsUserId = existing[0].id;
|
||||
}
|
||||
@@ -157,7 +158,9 @@ export const authOptions: NextAuthOptions = {
|
||||
cookies: {
|
||||
sessionToken: {
|
||||
// __Secure- prefix requires Secure; localhost HTTP needs plain name + secure: false
|
||||
name: isLocalNextAuth ? "next-auth.session-token" : "__Secure-next-auth.session-token",
|
||||
name: isLocalNextAuth
|
||||
? "next-auth.session-token"
|
||||
: "__Secure-next-auth.session-token",
|
||||
options: {
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
|
||||
76
lib/auth/password.ts
Normal file
76
lib/auth/password.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { scrypt, randomBytes, randomUUID, timingSafeEqual } from "crypto";
|
||||
import { promisify } from "util";
|
||||
import { query } from "@/lib/db-postgres";
|
||||
|
||||
const scryptAsync = promisify(scrypt) as (
|
||||
password: string,
|
||||
salt: string,
|
||||
keylen: number,
|
||||
) => Promise<Buffer>;
|
||||
|
||||
/**
|
||||
* Hash a password with scrypt (Node stdlib — no native deps, Alpine-safe).
|
||||
* Format: `scrypt$<saltHex>$<keyHex>`.
|
||||
*/
|
||||
export async function hashPassword(password: string): Promise<string> {
|
||||
const salt = randomBytes(16).toString("hex");
|
||||
const derived = await scryptAsync(password, salt, 64);
|
||||
return `scrypt$${salt}$${derived.toString("hex")}`;
|
||||
}
|
||||
|
||||
export async function verifyPassword(
|
||||
password: string,
|
||||
stored: string,
|
||||
): Promise<boolean> {
|
||||
const parts = stored.split("$");
|
||||
if (parts.length !== 3 || parts[0] !== "scrypt") return false;
|
||||
const [, salt, keyHex] = parts;
|
||||
const keyBuf = Buffer.from(keyHex, "hex");
|
||||
const derived = await scryptAsync(password, salt, 64);
|
||||
return keyBuf.length === derived.length && timingSafeEqual(keyBuf, derived);
|
||||
}
|
||||
|
||||
// ── Session cookie ───────────────────────────────────────────────────────────
|
||||
// Must match the cookie NextAuth issues for OAuth (see lib/auth/authOptions.ts)
|
||||
// so getServerSession reads sessions created here exactly the same way.
|
||||
const nextAuthUrl = (process.env.NEXTAUTH_URL ?? "").trim();
|
||||
const isLocalNextAuth =
|
||||
nextAuthUrl.startsWith("http://localhost") ||
|
||||
nextAuthUrl.startsWith("http://127.0.0.1") ||
|
||||
(process.env.NODE_ENV === "development" && !nextAuthUrl);
|
||||
|
||||
export const SESSION_COOKIE_NAME = isLocalNextAuth
|
||||
? "next-auth.session-token"
|
||||
: "__Secure-next-auth.session-token";
|
||||
|
||||
const SESSION_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
|
||||
|
||||
export function sessionCookieOptions(expires: Date) {
|
||||
return {
|
||||
httpOnly: true,
|
||||
sameSite: "lax" as const,
|
||||
path: "/",
|
||||
secure: !isLocalNextAuth,
|
||||
expires,
|
||||
...(isLocalNextAuth ? {} : { domain: ".vibnai.com" }),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a database-backed NextAuth session row for a user and return the token
|
||||
* to set as the session cookie. This is the same `sessions` table the Prisma
|
||||
* adapter uses for OAuth, so the resulting session works everywhere
|
||||
* (`authSession()`, `useSession()`, sign-out, etc.).
|
||||
*/
|
||||
export async function createDbSession(
|
||||
userId: string,
|
||||
): Promise<{ token: string; expires: Date }> {
|
||||
const token = randomBytes(32).toString("hex");
|
||||
const expires = new Date(Date.now() + SESSION_MAX_AGE_MS);
|
||||
await query(
|
||||
`INSERT INTO sessions (id, session_token, user_id, expires)
|
||||
VALUES ($1, $2, $3, $4)`,
|
||||
[randomUUID(), token, userId, expires],
|
||||
);
|
||||
return { token, expires };
|
||||
}
|
||||
Reference in New Issue
Block a user