feat(frontend): email+password auth, /signin + /signup pages, marketing consolidation, onboarding workspace naming + full data persistence

This commit is contained in:
2026-06-06 20:28:38 -07:00
parent 201171922d
commit c1d37184eb
49 changed files with 2753 additions and 6503 deletions

View File

@@ -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
View 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 };
}