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 { NextResponse } from "next/server";
|
||||||
import { createHash, randomBytes, scryptSync } from "crypto";
|
import { createHash } from "crypto";
|
||||||
import { getServiceSupabase } from "@/lib/supabase/client";
|
import { getServiceSupabase } from "@/lib/supabase/client";
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
@ -8,12 +8,6 @@ function hashToken(token: string): string {
|
|||||||
return createHash("sha256").update(token).digest("hex");
|
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) {
|
export async function POST(request: Request) {
|
||||||
try {
|
try {
|
||||||
const body = (await request.json()) as {
|
const body = (await request.json()) as {
|
||||||
@ -47,7 +41,7 @@ export async function POST(request: Request) {
|
|||||||
// Find valid token with user info
|
// Find valid token with user info
|
||||||
const { data: resetToken } = await supabase
|
const { data: resetToken } = await supabase
|
||||||
.from("password_reset_tokens")
|
.from("password_reset_tokens")
|
||||||
.select("id, user_id, users(email)")
|
.select("id, user_id, users(email, name)")
|
||||||
.eq("token_hash", tokenHash)
|
.eq("token_hash", tokenHash)
|
||||||
.eq("used", false)
|
.eq("used", false)
|
||||||
.gt("expires_at", now)
|
.gt("expires_at", now)
|
||||||
@ -64,22 +58,36 @@ export async function POST(request: Request) {
|
|||||||
const userEmail = Array.isArray(resetToken.users)
|
const userEmail = Array.isArray(resetToken.users)
|
||||||
? resetToken.users[0]?.email
|
? resetToken.users[0]?.email
|
||||||
: resetToken.users?.email;
|
: resetToken.users?.email;
|
||||||
|
const userName = Array.isArray(resetToken.users)
|
||||||
|
? resetToken.users[0]?.name
|
||||||
|
: resetToken.users?.name;
|
||||||
|
|
||||||
if (userEmail?.toLowerCase() !== email) {
|
if (userEmail?.toLowerCase() !== email) {
|
||||||
return NextResponse.json({ error: "Invalid reset token" }, { status: 400 });
|
return NextResponse.json({ error: "Invalid reset token" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hash new password
|
// Update Supabase Auth password. If auth user doesn't exist yet (legacy migration),
|
||||||
const passwordHash = hashPassword(password);
|
// create it with the same UUID so app foreign keys remain valid.
|
||||||
|
const { error: updateError } = await supabase.auth.admin.updateUserById(resetToken.user_id, {
|
||||||
// Update user password
|
password,
|
||||||
const { error: updateError } = await supabase
|
});
|
||||||
.from("users")
|
|
||||||
.update({ password_hash: passwordHash })
|
|
||||||
.eq("id", resetToken.user_id);
|
|
||||||
|
|
||||||
if (updateError) {
|
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
|
// 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 { cookies } from "next/headers";
|
||||||
|
import { createClient } from "@supabase/supabase-js";
|
||||||
import { getServiceSupabase } from "@/lib/supabase/client";
|
import { getServiceSupabase } from "@/lib/supabase/client";
|
||||||
|
import type { Database } from "@/lib/supabase/database.types";
|
||||||
|
|
||||||
const SESSION_COOKIE_NAME = "gantt_session";
|
const SESSION_COOKIE_NAME = "gantt_session";
|
||||||
const SESSION_HOURS_SHORT = 12;
|
const SESSION_HOURS_SHORT = 12;
|
||||||
@ -19,10 +21,24 @@ interface UserRow {
|
|||||||
name: string;
|
name: string;
|
||||||
email: string;
|
email: string;
|
||||||
avatar_url: string | null;
|
avatar_url: string | null;
|
||||||
password_hash: string;
|
|
||||||
created_at: 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 {
|
function normalizeEmail(email: string): string {
|
||||||
return email.trim().toLowerCase();
|
return email.trim().toLowerCase();
|
||||||
}
|
}
|
||||||
@ -40,22 +56,11 @@ function normalizeAvatarUrl(value: string | null | undefined): string | undefine
|
|||||||
return trimmed;
|
return trimmed;
|
||||||
}
|
}
|
||||||
|
|
||||||
function hashPassword(password: string, salt?: string): string {
|
function fallbackNameFromEmail(email: string, preferredName?: string | null): string {
|
||||||
const safeSalt = salt || randomBytes(16).toString("hex");
|
const trimmed = (preferredName || "").trim();
|
||||||
const derived = scryptSync(password, safeSalt, 64).toString("hex");
|
if (trimmed.length >= 2) return trimmed;
|
||||||
return `scrypt$${safeSalt}$${derived}`;
|
const base = email.split("@")[0]?.trim() || "User";
|
||||||
}
|
return base.length >= 2 ? base : "User";
|
||||||
|
|
||||||
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 hashSessionToken(token: string): string {
|
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: {
|
export async function registerUser(params: {
|
||||||
name: string;
|
name: string;
|
||||||
email: 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");
|
if (password.length < 8) throw new Error("Password must be at least 8 characters");
|
||||||
|
|
||||||
const supabase = getServiceSupabase();
|
const supabase = getServiceSupabase();
|
||||||
|
const { data, error } = await supabase.auth.admin.createUser({
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
email_confirm: true,
|
||||||
|
user_metadata: { name },
|
||||||
|
});
|
||||||
|
|
||||||
// Check if email already exists
|
if (error || !data.user) {
|
||||||
const { data: existing } = await supabase
|
const message = error?.message || "Failed to create user";
|
||||||
.from("users")
|
if (message.toLowerCase().includes("already")) {
|
||||||
.select("id")
|
throw new Error("Email already exists");
|
||||||
.eq("email", email)
|
}
|
||||||
.maybeSingle();
|
throw new Error(message);
|
||||||
|
|
||||||
if (existing) {
|
|
||||||
throw new Error("Email already exists");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create user
|
const authUser = data.user;
|
||||||
const { data: user, error } = await supabase
|
const userName = fallbackNameFromEmail(email, name);
|
||||||
|
await upsertUserMirrors({
|
||||||
|
id: authUser.id,
|
||||||
|
name: userName,
|
||||||
|
email,
|
||||||
|
avatarUrl: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: userRow, error: userError } = await supabase
|
||||||
.from("users")
|
.from("users")
|
||||||
.insert({
|
|
||||||
name,
|
|
||||||
email,
|
|
||||||
password_hash: hashPassword(password),
|
|
||||||
})
|
|
||||||
.select("id, name, email, avatar_url, created_at")
|
.select("id, name, email, avatar_url, created_at")
|
||||||
|
.eq("id", authUser.id)
|
||||||
.single();
|
.single();
|
||||||
|
|
||||||
if (error || !user) {
|
if (userError || !userRow) {
|
||||||
throw new Error(error?.message || "Failed to create user");
|
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: {
|
export async function authenticateUser(params: {
|
||||||
@ -137,16 +185,42 @@ export async function authenticateUser(params: {
|
|||||||
await deleteExpiredSessions();
|
await deleteExpiredSessions();
|
||||||
|
|
||||||
const email = normalizeEmail(params.email);
|
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 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
|
const { data: row } = await supabase
|
||||||
.from("users")
|
.from("users")
|
||||||
.select("id, name, email, avatar_url, password_hash, created_at")
|
.select("id, name, email, avatar_url, created_at")
|
||||||
.eq("email", email)
|
.eq("id", authUser.id)
|
||||||
.maybeSingle();
|
.maybeSingle();
|
||||||
|
|
||||||
if (!row) return null;
|
if (!row) return null;
|
||||||
if (!verifyPassword(params.password, row.password_hash)) return null;
|
|
||||||
|
|
||||||
return mapUserRow(row as UserRow);
|
return mapUserRow(row as UserRow);
|
||||||
}
|
}
|
||||||
@ -166,7 +240,7 @@ export async function updateUserAccount(params: {
|
|||||||
// Get current user data
|
// Get current user data
|
||||||
const { data: row } = await supabase
|
const { data: row } = await supabase
|
||||||
.from("users")
|
.from("users")
|
||||||
.select("id, name, email, avatar_url, password_hash, created_at")
|
.select("id, name, email, avatar_url, created_at")
|
||||||
.eq("id", params.userId)
|
.eq("id", params.userId)
|
||||||
.single();
|
.single();
|
||||||
|
|
||||||
@ -189,7 +263,12 @@ export async function updateUserAccount(params: {
|
|||||||
|
|
||||||
if (needsPasswordCheck) {
|
if (needsPasswordCheck) {
|
||||||
if (!currentPassword) throw new Error("Current password is required");
|
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");
|
throw new Error("Current password is incorrect");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -205,7 +284,27 @@ export async function updateUserAccount(params: {
|
|||||||
if (existing) throw new Error("Email already exists");
|
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
|
const { data: updated, error } = await supabase
|
||||||
.from("users")
|
.from("users")
|
||||||
@ -213,7 +312,6 @@ export async function updateUserAccount(params: {
|
|||||||
name: requestedName,
|
name: requestedName,
|
||||||
email: requestedEmail,
|
email: requestedEmail,
|
||||||
avatar_url: requestedAvatar ?? null,
|
avatar_url: requestedAvatar ?? null,
|
||||||
password_hash: nextPasswordHash,
|
|
||||||
})
|
})
|
||||||
.eq("id", row.id)
|
.eq("id", row.id)
|
||||||
.select("id, name, email, avatar_url, created_at")
|
.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");
|
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);
|
return mapUserRow(updated as UserRow);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user