Signed-off-by: OpenClaw Bot <ai-agent@topdoglabs.com>

This commit is contained in:
OpenClaw Bot 2026-02-21 12:57:27 -06:00
parent 6f863ba659
commit 82658707a2
2 changed files with 181 additions and 61 deletions

View File

@ -1,5 +1,5 @@
import { NextResponse } from "next/server";
import { createHash, randomBytes, scryptSync } from "crypto";
import { createHash } from "crypto";
import { getServiceSupabase } from "@/lib/supabase/client";
export const runtime = "nodejs";
@ -8,12 +8,6 @@ function hashToken(token: string): string {
return createHash("sha256").update(token).digest("hex");
}
function hashPassword(password: string, salt?: string): string {
const safeSalt = salt || randomBytes(16).toString("hex");
const derived = scryptSync(password, safeSalt, 64).toString("hex");
return `scrypt$${safeSalt}$${derived}`;
}
export async function POST(request: Request) {
try {
const body = (await request.json()) as {
@ -47,7 +41,7 @@ export async function POST(request: Request) {
// Find valid token with user info
const { data: resetToken } = await supabase
.from("password_reset_tokens")
.select("id, user_id, users(email)")
.select("id, user_id, users(email, name)")
.eq("token_hash", tokenHash)
.eq("used", false)
.gt("expires_at", now)
@ -64,22 +58,36 @@ export async function POST(request: Request) {
const userEmail = Array.isArray(resetToken.users)
? resetToken.users[0]?.email
: resetToken.users?.email;
const userName = Array.isArray(resetToken.users)
? resetToken.users[0]?.name
: resetToken.users?.name;
if (userEmail?.toLowerCase() !== email) {
return NextResponse.json({ error: "Invalid reset token" }, { status: 400 });
}
// Hash new password
const passwordHash = hashPassword(password);
// Update user password
const { error: updateError } = await supabase
.from("users")
.update({ password_hash: passwordHash })
.eq("id", resetToken.user_id);
// Update Supabase Auth password. If auth user doesn't exist yet (legacy migration),
// create it with the same UUID so app foreign keys remain valid.
const { error: updateError } = await supabase.auth.admin.updateUserById(resetToken.user_id, {
password,
});
if (updateError) {
throw updateError;
const updateMessage = updateError.message || "";
if (updateMessage.toLowerCase().includes("not found")) {
const { error: createError } = await supabase.auth.admin.createUser({
id: resetToken.user_id,
email,
password,
email_confirm: true,
user_metadata: {
name: typeof userName === "string" && userName.trim().length > 0 ? userName : undefined,
},
});
if (createError) throw createError;
} else {
throw updateError;
}
}
// Mark token as used

View File

@ -1,6 +1,8 @@
import { randomBytes, scryptSync, timingSafeEqual, createHash } from "crypto";
import { randomBytes, createHash } from "crypto";
import { cookies } from "next/headers";
import { createClient } from "@supabase/supabase-js";
import { getServiceSupabase } from "@/lib/supabase/client";
import type { Database } from "@/lib/supabase/database.types";
const SESSION_COOKIE_NAME = "gantt_session";
const SESSION_HOURS_SHORT = 12;
@ -19,10 +21,24 @@ interface UserRow {
name: string;
email: string;
avatar_url: string | null;
password_hash: string;
created_at: string;
}
function getPublicSupabase() {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
if (!supabaseUrl || !supabaseAnonKey) {
throw new Error("Missing Supabase public environment variables");
}
return createClient<Database>(supabaseUrl, supabaseAnonKey, {
auth: {
autoRefreshToken: false,
persistSession: false,
},
});
}
function normalizeEmail(email: string): string {
return email.trim().toLowerCase();
}
@ -40,22 +56,11 @@ function normalizeAvatarUrl(value: string | null | undefined): string | undefine
return trimmed;
}
function hashPassword(password: string, salt?: string): string {
const safeSalt = salt || randomBytes(16).toString("hex");
const derived = scryptSync(password, safeSalt, 64).toString("hex");
return `scrypt$${safeSalt}$${derived}`;
}
function verifyPassword(password: string, stored: string): boolean {
const parts = stored.split("$");
if (parts.length !== 3 || parts[0] !== "scrypt") return false;
const [, salt, digest] = parts;
const candidate = hashPassword(password, salt);
const candidateDigest = candidate.split("$")[2];
const a = Buffer.from(digest, "hex");
const b = Buffer.from(candidateDigest, "hex");
if (a.length !== b.length) return false;
return timingSafeEqual(a, b);
function fallbackNameFromEmail(email: string, preferredName?: string | null): string {
const trimmed = (preferredName || "").trim();
if (trimmed.length >= 2) return trimmed;
const base = email.split("@")[0]?.trim() || "User";
return base.length >= 2 ? base : "User";
}
function hashSessionToken(token: string): string {
@ -84,6 +89,42 @@ function mapUserRow(row: UserRow): AuthUser {
};
}
async function upsertUserMirrors(params: {
id: string;
name: string;
email: string;
avatarUrl?: string | null;
}) {
const supabase = getServiceSupabase();
const { error: userError } = await supabase.from("users").upsert(
{
id: params.id,
name: params.name,
email: normalizeEmail(params.email),
avatar_url: params.avatarUrl ?? null,
},
{ onConflict: "id" }
);
if (userError) {
throw new Error(userError.message || "Failed to sync user profile");
}
const { error: profileError } = await supabase.from("profiles").upsert(
{
id: params.id,
name: params.name,
avatar_url: params.avatarUrl ?? null,
updated_at: new Date().toISOString(),
},
{ onConflict: "id" }
);
if (profileError) {
console.error("Failed to sync profiles mirror:", profileError.message);
}
}
export async function registerUser(params: {
name: string;
email: string;
@ -100,34 +141,41 @@ export async function registerUser(params: {
if (password.length < 8) throw new Error("Password must be at least 8 characters");
const supabase = getServiceSupabase();
const { data, error } = await supabase.auth.admin.createUser({
email,
password,
email_confirm: true,
user_metadata: { name },
});
// Check if email already exists
const { data: existing } = await supabase
.from("users")
.select("id")
.eq("email", email)
.maybeSingle();
if (existing) {
throw new Error("Email already exists");
if (error || !data.user) {
const message = error?.message || "Failed to create user";
if (message.toLowerCase().includes("already")) {
throw new Error("Email already exists");
}
throw new Error(message);
}
// Create user
const { data: user, error } = await supabase
const authUser = data.user;
const userName = fallbackNameFromEmail(email, name);
await upsertUserMirrors({
id: authUser.id,
name: userName,
email,
avatarUrl: null,
});
const { data: userRow, error: userError } = await supabase
.from("users")
.insert({
name,
email,
password_hash: hashPassword(password),
})
.select("id, name, email, avatar_url, created_at")
.eq("id", authUser.id)
.single();
if (error || !user) {
throw new Error(error?.message || "Failed to create user");
if (userError || !userRow) {
throw new Error(userError?.message || "Failed to load user profile");
}
return mapUserRow(user as UserRow);
return mapUserRow(userRow as UserRow);
}
export async function authenticateUser(params: {
@ -137,16 +185,42 @@ export async function authenticateUser(params: {
await deleteExpiredSessions();
const email = normalizeEmail(params.email);
const authClient = getPublicSupabase();
const { data: signInData, error: signInError } = await authClient.auth.signInWithPassword({
email,
password: params.password,
});
if (signInError || !signInData.user) return null;
const supabase = getServiceSupabase();
const authUser = signInData.user;
const { data: existingRow } = await supabase
.from("users")
.select("id, name, email, avatar_url, created_at")
.eq("id", authUser.id)
.maybeSingle();
const userName = fallbackNameFromEmail(
authUser.email || email,
typeof authUser.user_metadata?.name === "string" ? authUser.user_metadata.name : existingRow?.name
);
await upsertUserMirrors({
id: authUser.id,
name: userName,
email: authUser.email || email,
avatarUrl: existingRow?.avatar_url || null,
});
const { data: row } = await supabase
.from("users")
.select("id, name, email, avatar_url, password_hash, created_at")
.eq("email", email)
.select("id, name, email, avatar_url, created_at")
.eq("id", authUser.id)
.maybeSingle();
if (!row) return null;
if (!verifyPassword(params.password, row.password_hash)) return null;
return mapUserRow(row as UserRow);
}
@ -166,7 +240,7 @@ export async function updateUserAccount(params: {
// Get current user data
const { data: row } = await supabase
.from("users")
.select("id, name, email, avatar_url, password_hash, created_at")
.select("id, name, email, avatar_url, created_at")
.eq("id", params.userId)
.single();
@ -189,7 +263,12 @@ export async function updateUserAccount(params: {
if (needsPasswordCheck) {
if (!currentPassword) throw new Error("Current password is required");
if (!verifyPassword(currentPassword, row.password_hash)) {
const authClient = getPublicSupabase();
const { error: authError } = await authClient.auth.signInWithPassword({
email: row.email,
password: currentPassword,
});
if (authError) {
throw new Error("Current password is incorrect");
}
}
@ -205,7 +284,27 @@ export async function updateUserAccount(params: {
if (existing) throw new Error("Email already exists");
}
const nextPasswordHash = passwordChanged ? hashPassword(newPassword) : row.password_hash;
if (emailChanged || passwordChanged || requestedName !== row.name) {
const updatePayload: {
email?: string;
password?: string;
user_metadata?: { name: string };
} = {
user_metadata: { name: requestedName },
};
if (emailChanged) updatePayload.email = requestedEmail;
if (passwordChanged) updatePayload.password = newPassword;
const { error: authUpdateError } = await supabase.auth.admin.updateUserById(row.id, updatePayload);
if (authUpdateError) {
const message = authUpdateError.message || "Failed to update account";
if (message.toLowerCase().includes("already")) {
throw new Error("Email already exists");
}
throw new Error(message);
}
}
const { data: updated, error } = await supabase
.from("users")
@ -213,7 +312,6 @@ export async function updateUserAccount(params: {
name: requestedName,
email: requestedEmail,
avatar_url: requestedAvatar ?? null,
password_hash: nextPasswordHash,
})
.eq("id", row.id)
.select("id, name, email, avatar_url, created_at")
@ -223,6 +321,20 @@ export async function updateUserAccount(params: {
throw new Error(error?.message || "Failed to update user");
}
const { error: profileError } = await supabase.from("profiles").upsert(
{
id: row.id,
name: requestedName,
avatar_url: requestedAvatar ?? null,
updated_at: new Date().toISOString(),
},
{ onConflict: "id" }
);
if (profileError) {
console.error("Failed to sync profiles mirror:", profileError.message);
}
return mapUserRow(updated as UserRow);
}