456 lines
12 KiB
TypeScript
456 lines
12 KiB
TypeScript
import { randomBytes, createHash } from "crypto";
|
|
import { cookies } from "next/headers";
|
|
import { createClient } from "@supabase/supabase-js";
|
|
import { getServiceSupabase } from "@/lib/supabase/client";
|
|
|
|
const SESSION_COOKIE_NAME = "mission_control_session";
|
|
const SESSION_HOURS_SHORT = 12;
|
|
const SESSION_DAYS_REMEMBER = 30;
|
|
|
|
export interface AuthUser {
|
|
id: string;
|
|
name: string;
|
|
email: string;
|
|
avatarUrl?: string;
|
|
createdAt: string;
|
|
}
|
|
|
|
interface UserRow {
|
|
id: string;
|
|
name: string;
|
|
email: string;
|
|
avatar_url: string | null;
|
|
created_at: string;
|
|
}
|
|
|
|
function getPublicSupabase() {
|
|
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL || process.env.SUPABASE_URL;
|
|
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY || process.env.SUPABASE_ANON_KEY;
|
|
if (!supabaseUrl || !supabaseAnonKey) {
|
|
throw new Error("Missing Supabase public environment variables");
|
|
}
|
|
|
|
return createClient(supabaseUrl, supabaseAnonKey, {
|
|
auth: {
|
|
autoRefreshToken: false,
|
|
persistSession: false,
|
|
},
|
|
});
|
|
}
|
|
|
|
function normalizeEmail(email: string): string {
|
|
return email.trim().toLowerCase();
|
|
}
|
|
|
|
function normalizeAvatarUrl(value: string | null | undefined): string | undefined {
|
|
if (value == null) return undefined;
|
|
const trimmed = value.trim();
|
|
if (!trimmed) return undefined;
|
|
if (!trimmed.startsWith("data:image/")) {
|
|
throw new Error("Avatar must be an image");
|
|
}
|
|
if (trimmed.length > 2_000_000) {
|
|
throw new Error("Avatar image is too large");
|
|
}
|
|
return trimmed;
|
|
}
|
|
|
|
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 {
|
|
return createHash("sha256").update(token).digest("hex");
|
|
}
|
|
|
|
async function deleteExpiredSessions() {
|
|
const supabase = getServiceSupabase();
|
|
const { error } = await supabase
|
|
.from("sessions")
|
|
.delete()
|
|
.lte("expires_at", new Date().toISOString());
|
|
|
|
if (error) {
|
|
console.error("Failed to delete expired sessions:", error);
|
|
}
|
|
}
|
|
|
|
function mapUserRow(row: UserRow): AuthUser {
|
|
return {
|
|
id: row.id,
|
|
name: row.name,
|
|
email: row.email,
|
|
avatarUrl: row.avatar_url ?? undefined,
|
|
createdAt: row.created_at,
|
|
};
|
|
}
|
|
|
|
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;
|
|
password: string;
|
|
}): Promise<AuthUser> {
|
|
await deleteExpiredSessions();
|
|
|
|
const name = params.name.trim();
|
|
const email = normalizeEmail(params.email);
|
|
const password = params.password;
|
|
|
|
if (name.length < 2) throw new Error("Name must be at least 2 characters");
|
|
if (!email.includes("@")) throw new Error("Invalid email");
|
|
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 },
|
|
});
|
|
|
|
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);
|
|
}
|
|
|
|
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")
|
|
.select("id, name, email, avatar_url, created_at")
|
|
.eq("id", authUser.id)
|
|
.single();
|
|
|
|
if (userError || !userRow) {
|
|
throw new Error(userError?.message || "Failed to load user profile");
|
|
}
|
|
|
|
return mapUserRow(userRow as UserRow);
|
|
}
|
|
|
|
export async function authenticateUser(params: {
|
|
email: string;
|
|
password: string;
|
|
}): Promise<AuthUser | null> {
|
|
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, created_at")
|
|
.eq("id", authUser.id)
|
|
.maybeSingle();
|
|
|
|
if (!row) return null;
|
|
|
|
return mapUserRow(row as UserRow);
|
|
}
|
|
|
|
export async function updateUserAccount(params: {
|
|
userId: string;
|
|
name?: string;
|
|
email?: string;
|
|
avatarUrl?: string | null;
|
|
currentPassword?: string;
|
|
newPassword?: string;
|
|
}): Promise<AuthUser> {
|
|
await deleteExpiredSessions();
|
|
|
|
const supabase = getServiceSupabase();
|
|
|
|
// Get current user data
|
|
const { data: row } = await supabase
|
|
.from("users")
|
|
.select("id, name, email, avatar_url, created_at")
|
|
.eq("id", params.userId)
|
|
.single();
|
|
|
|
if (!row) throw new Error("User not found");
|
|
|
|
const requestedName = typeof params.name === "string" ? params.name.trim() : row.name;
|
|
const requestedEmail = typeof params.email === "string" ? normalizeEmail(params.email) : row.email;
|
|
const hasAvatarInput = Object.prototype.hasOwnProperty.call(params, "avatarUrl");
|
|
const requestedAvatar = hasAvatarInput ? normalizeAvatarUrl(params.avatarUrl) : row.avatar_url;
|
|
const currentPassword = params.currentPassword || "";
|
|
const newPassword = params.newPassword || "";
|
|
|
|
if (requestedName.length < 2) throw new Error("Name must be at least 2 characters");
|
|
if (!requestedEmail.includes("@")) throw new Error("Invalid email");
|
|
if (newPassword && newPassword.length < 8) throw new Error("New password must be at least 8 characters");
|
|
|
|
const emailChanged = requestedEmail !== row.email;
|
|
const passwordChanged = newPassword.length > 0;
|
|
const needsPasswordCheck = emailChanged || passwordChanged;
|
|
|
|
if (needsPasswordCheck) {
|
|
if (!currentPassword) throw new Error("Current password is required");
|
|
const authClient = getPublicSupabase();
|
|
const { error: authError } = await authClient.auth.signInWithPassword({
|
|
email: row.email,
|
|
password: currentPassword,
|
|
});
|
|
if (authError) {
|
|
throw new Error("Current password is incorrect");
|
|
}
|
|
}
|
|
|
|
if (emailChanged) {
|
|
const { data: existing } = await supabase
|
|
.from("users")
|
|
.select("id")
|
|
.eq("email", requestedEmail)
|
|
.neq("id", row.id)
|
|
.maybeSingle();
|
|
|
|
if (existing) throw new Error("Email already exists");
|
|
}
|
|
|
|
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")
|
|
.update({
|
|
name: requestedName,
|
|
email: requestedEmail,
|
|
avatar_url: requestedAvatar ?? null,
|
|
})
|
|
.eq("id", row.id)
|
|
.select("id, name, email, avatar_url, created_at")
|
|
.single();
|
|
|
|
if (error || !updated) {
|
|
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);
|
|
}
|
|
|
|
export async function listUsers(): Promise<AuthUser[]> {
|
|
const supabase = getServiceSupabase();
|
|
|
|
const { data: rows, error } = await supabase
|
|
.from("users")
|
|
.select("id, name, email, avatar_url, created_at")
|
|
.order("name", { ascending: true });
|
|
|
|
if (error) {
|
|
console.error("Failed to list users:", error);
|
|
return [];
|
|
}
|
|
|
|
return (rows || []).map(mapUserRow);
|
|
}
|
|
|
|
export async function createUserSession(
|
|
userId: string,
|
|
rememberMe: boolean
|
|
): Promise<{ token: string; expiresAt: string }> {
|
|
await deleteExpiredSessions();
|
|
|
|
const supabase = getServiceSupabase();
|
|
const now = Date.now();
|
|
const ttlMs = rememberMe
|
|
? SESSION_DAYS_REMEMBER * 24 * 60 * 60 * 1000
|
|
: SESSION_HOURS_SHORT * 60 * 60 * 1000;
|
|
|
|
const createdAt = new Date(now).toISOString();
|
|
const expiresAt = new Date(now + ttlMs).toISOString();
|
|
const token = randomBytes(32).toString("hex");
|
|
const tokenHash = hashSessionToken(token);
|
|
|
|
const { error } = await supabase.from("sessions").insert({
|
|
user_id: userId,
|
|
token_hash: tokenHash,
|
|
created_at: createdAt,
|
|
expires_at: expiresAt,
|
|
});
|
|
|
|
if (error) {
|
|
throw new Error("Failed to create session");
|
|
}
|
|
|
|
return { token, expiresAt };
|
|
}
|
|
|
|
export async function revokeSession(token: string): Promise<void> {
|
|
const supabase = getServiceSupabase();
|
|
const tokenHash = hashSessionToken(token);
|
|
|
|
await supabase.from("sessions").delete().eq("token_hash", tokenHash);
|
|
}
|
|
|
|
export async function getUserBySessionToken(token: string): Promise<AuthUser | null> {
|
|
await deleteExpiredSessions();
|
|
|
|
const supabase = getServiceSupabase();
|
|
const tokenHash = hashSessionToken(token);
|
|
const now = new Date().toISOString();
|
|
|
|
const { data: row } = await supabase
|
|
.from("sessions")
|
|
.select("user_id, users(id, name, email, avatar_url, created_at)")
|
|
.eq("token_hash", tokenHash)
|
|
.gt("expires_at", now)
|
|
.maybeSingle();
|
|
|
|
if (!row || !row.users) return null;
|
|
|
|
const user = Array.isArray(row.users) ? row.users[0] : row.users;
|
|
return mapUserRow(user as UserRow);
|
|
}
|
|
|
|
export async function setSessionCookie(token: string, rememberMe: boolean): Promise<void> {
|
|
const cookieStore = await cookies();
|
|
const baseOptions = {
|
|
httpOnly: true,
|
|
sameSite: "lax" as const,
|
|
secure: process.env.NODE_ENV === "production",
|
|
path: "/",
|
|
};
|
|
|
|
if (rememberMe) {
|
|
cookieStore.set(SESSION_COOKIE_NAME, token, {
|
|
...baseOptions,
|
|
maxAge: SESSION_DAYS_REMEMBER * 24 * 60 * 60,
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Session cookie (clears on browser close)
|
|
cookieStore.set(SESSION_COOKIE_NAME, token, baseOptions);
|
|
}
|
|
|
|
export async function clearSessionCookie(): Promise<void> {
|
|
const cookieStore = await cookies();
|
|
cookieStore.set(SESSION_COOKIE_NAME, "", {
|
|
httpOnly: true,
|
|
sameSite: "lax" as const,
|
|
secure: process.env.NODE_ENV === "production",
|
|
path: "/",
|
|
maxAge: 0,
|
|
});
|
|
}
|
|
|
|
export async function getSessionTokenFromCookies(): Promise<string | null> {
|
|
const cookieStore = await cookies();
|
|
return cookieStore.get(SESSION_COOKIE_NAME)?.value ?? null;
|
|
}
|
|
|
|
export async function getAuthenticatedUser(): Promise<AuthUser | null> {
|
|
const token = await getSessionTokenFromCookies();
|
|
if (!token) return null;
|
|
return getUserBySessionToken(token);
|
|
}
|