Signed-off-by: OpenClaw Bot <ai-agent@topdoglabs.com>
This commit is contained in:
parent
6f863ba659
commit
82658707a2
@ -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
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user