From 6efd626bc35958a3ed38c90f1366b5cd404d1d6c Mon Sep 17 00:00:00 2001 From: wlenig <30681316+wlenig@users.noreply.github.com> Date: Tue, 7 Apr 2026 18:51:38 -0400 Subject: [PATCH 1/6] AUTH-7 Add User server actions, supabase client --- src/actions/users.ts | 83 ++++++++++++++++++++++++++++++++++++++++++++ src/lib/supabase.ts | 17 +++++++++ 2 files changed, 100 insertions(+) create mode 100644 src/actions/users.ts create mode 100644 src/lib/supabase.ts diff --git a/src/actions/users.ts b/src/actions/users.ts new file mode 100644 index 0000000..eceb40f --- /dev/null +++ b/src/actions/users.ts @@ -0,0 +1,83 @@ +"use server"; + +import { prisma } from "@/lib/prisma"; +import { supabaseAdmin } from "@/lib/supabase"; +import type { User } from "@/generated/prisma/client"; + +export async function createUser(supabaseUserId: string): Promise { + const { error } = + await supabaseAdmin.auth.admin.getUserById(supabaseUserId); + + if (error) { + throw new Error("Supabase auth user not found"); + } + + return prisma.user.create({ data: { supabaseUserId } }); +} + +export async function getUser( + id: string, + options?: { includeEmail?: boolean } +): Promise { + const user = await prisma.user.findUnique({ where: { id } }); + + if (!user) { + throw new Error("User not found"); + } + + if (!options?.includeEmail) { + return user; + } + + const { data, error } = await supabaseAdmin.auth.admin.getUserById( + user.supabaseUserId + ); + + if (error) { + throw new Error(error.message); + } + + if (!data.user) { + throw new Error("Supabase auth user not found"); + } + + return { ...user, email: data.user.email }; +} + +export async function getUsers(filters?: { + isAdmin?: boolean; +}): Promise { + return prisma.user.findMany({ + where: filters, + orderBy: { createdAt: "desc" }, + }); +} + +export async function updateUser( + id: string, + data: { isAdmin?: boolean } +): Promise { + return prisma.user.update({ where: { id }, data }); +} + +export async function deleteUser(id: string): Promise { + const user = await prisma.user.findUnique({ where: { id } }); + + if (!user) { + throw new Error("User not found"); + } + + const { error } = await supabaseAdmin.auth.admin.deleteUser( + user.supabaseUserId + ); + + if (error) { + throw new Error("Failed to delete Supabase auth user"); + } + + await prisma.$transaction([ + prisma.session.deleteMany({ where: { userId: id } }), + prisma.userProject.deleteMany({ where: { userId: id } }), + prisma.user.delete({ where: { id } }), + ]); +} diff --git a/src/lib/supabase.ts b/src/lib/supabase.ts new file mode 100644 index 0000000..d4857b1 --- /dev/null +++ b/src/lib/supabase.ts @@ -0,0 +1,17 @@ +import "server-only"; +import { createClient } from "@supabase/supabase-js"; + +const globalForSupabase = globalThis as unknown as { + supabase?: ReturnType; +}; + +export const supabaseAdmin = + globalForSupabase.supabase ?? + createClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.SUPABASE_SERVICE_ROLE_KEY! + ); + +if (process.env.NODE_ENV !== "production") { + globalForSupabase.supabase = supabaseAdmin; +} From f12c22a1ba71b1020fee5257ff872bdd51765d03 Mon Sep 17 00:00:00 2001 From: wlenig <30681316+wlenig@users.noreply.github.com> Date: Tue, 7 Apr 2026 19:01:28 -0400 Subject: [PATCH 2/6] AUTH-7 Remove redundant getUser error check --- src/actions/users.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/actions/users.ts b/src/actions/users.ts index eceb40f..3a4a2ae 100644 --- a/src/actions/users.ts +++ b/src/actions/users.ts @@ -37,10 +37,6 @@ export async function getUser( throw new Error(error.message); } - if (!data.user) { - throw new Error("Supabase auth user not found"); - } - return { ...user, email: data.user.email }; } From 119a9f75c9564096d3943c0a0c0d3fdf52004a73 Mon Sep 17 00:00:00 2001 From: wlenig <30681316+wlenig@users.noreply.github.com> Date: Thu, 9 Apr 2026 00:23:10 -0400 Subject: [PATCH 3/6] AUTH-7 Move user actions to `lib/` --- src/{actions => lib}/users.ts | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename src/{actions => lib}/users.ts (100%) diff --git a/src/actions/users.ts b/src/lib/users.ts similarity index 100% rename from src/actions/users.ts rename to src/lib/users.ts From b4775ed4c391c783afd3b0831065a348a1718713 Mon Sep 17 00:00:00 2001 From: wlenig <30681316+wlenig@users.noreply.github.com> Date: Thu, 9 Apr 2026 01:01:09 -0400 Subject: [PATCH 4/6] AUTH-7 Add documentation --- src/lib/users.ts | 33 +++++++++++++++++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/src/lib/users.ts b/src/lib/users.ts index 3a4a2ae..9c648bc 100644 --- a/src/lib/users.ts +++ b/src/lib/users.ts @@ -4,6 +4,11 @@ import { prisma } from "@/lib/prisma"; import { supabaseAdmin } from "@/lib/supabase"; import type { User } from "@/generated/prisma/client"; +/** + * Creates a new User linked to a pre-existing Supabase auth user + * @param supabaseUserId Supabase UID + * @returns The created User + */ export async function createUser(supabaseUserId: string): Promise { const { error } = await supabaseAdmin.auth.admin.getUserById(supabaseUserId); @@ -15,9 +20,16 @@ export async function createUser(supabaseUserId: string): Promise { return prisma.user.create({ data: { supabaseUserId } }); } +/** + * Gets a User by ID, optionally including their Supabase auth email + * @param id User UUID to lookup + * @param includeEmail Whether to include the user's auth email + * @returns The fetched User, with email if requested + * @throws If the User is not found or if there's an error fetching the email + */ export async function getUser( id: string, - options?: { includeEmail?: boolean } + includeEmail?: boolean ): Promise { const user = await prisma.user.findUnique({ where: { id } }); @@ -25,7 +37,7 @@ export async function getUser( throw new Error("User not found"); } - if (!options?.includeEmail) { + if (!includeEmail) { return user; } @@ -40,6 +52,11 @@ export async function getUser( return { ...user, email: data.user.email }; } +/** + * Gets all users with filters + * @param filters An optional filter by isAdmin status + * @returns The list of Users + */ export async function getUsers(filters?: { isAdmin?: boolean; }): Promise { @@ -49,6 +66,12 @@ export async function getUsers(filters?: { }); } +/** + * Updates User fields by ID + * @param id User UUID to update + * @param data The data to update (e.g. isAdmin status) + * @returns The updated User + */ export async function updateUser( id: string, data: { isAdmin?: boolean } @@ -56,6 +79,12 @@ export async function updateUser( return prisma.user.update({ where: { id }, data }); } +/** + * Deletes a User by ID. Attempts to delete the linked Supabase auth user first, + * then hard deletes all related data in a transaction + * @param id User UUID to delete + * @throws If the User is not found, or the Supabase auth user deletion fails + */ export async function deleteUser(id: string): Promise { const user = await prisma.user.findUnique({ where: { id } }); From 7e7beb0caf2ccc4dc7330c11da518d0e2733fe7c Mon Sep 17 00:00:00 2001 From: wlenig <30681316+wlenig@users.noreply.github.com> Date: Thu, 9 Apr 2026 01:09:12 -0400 Subject: [PATCH 5/6] AUTH-7 Prettier --- src/lib/supabase.ts | 2 +- src/lib/users.ts | 13 ++++++------- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/src/lib/supabase.ts b/src/lib/supabase.ts index d4857b1..718a5c3 100644 --- a/src/lib/supabase.ts +++ b/src/lib/supabase.ts @@ -9,7 +9,7 @@ export const supabaseAdmin = globalForSupabase.supabase ?? createClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, - process.env.SUPABASE_SERVICE_ROLE_KEY! + process.env.SUPABASE_SERVICE_ROLE_KEY!, ); if (process.env.NODE_ENV !== "production") { diff --git a/src/lib/users.ts b/src/lib/users.ts index 9c648bc..1bfcbd6 100644 --- a/src/lib/users.ts +++ b/src/lib/users.ts @@ -10,8 +10,7 @@ import type { User } from "@/generated/prisma/client"; * @returns The created User */ export async function createUser(supabaseUserId: string): Promise { - const { error } = - await supabaseAdmin.auth.admin.getUserById(supabaseUserId); + const { error } = await supabaseAdmin.auth.admin.getUserById(supabaseUserId); if (error) { throw new Error("Supabase auth user not found"); @@ -29,7 +28,7 @@ export async function createUser(supabaseUserId: string): Promise { */ export async function getUser( id: string, - includeEmail?: boolean + includeEmail?: boolean, ): Promise { const user = await prisma.user.findUnique({ where: { id } }); @@ -42,7 +41,7 @@ export async function getUser( } const { data, error } = await supabaseAdmin.auth.admin.getUserById( - user.supabaseUserId + user.supabaseUserId, ); if (error) { @@ -74,13 +73,13 @@ export async function getUsers(filters?: { */ export async function updateUser( id: string, - data: { isAdmin?: boolean } + data: { isAdmin?: boolean }, ): Promise { return prisma.user.update({ where: { id }, data }); } /** - * Deletes a User by ID. Attempts to delete the linked Supabase auth user first, + * Deletes a User by ID. Attempts to delete the linked Supabase auth user first, * then hard deletes all related data in a transaction * @param id User UUID to delete * @throws If the User is not found, or the Supabase auth user deletion fails @@ -93,7 +92,7 @@ export async function deleteUser(id: string): Promise { } const { error } = await supabaseAdmin.auth.admin.deleteUser( - user.supabaseUserId + user.supabaseUserId, ); if (error) { From e3cd9fd7ebc7d75fbac0bddf735058bd05f8f8f6 Mon Sep 17 00:00:00 2001 From: pataniaeli Date: Sun, 16 Aug 2026 11:08:05 -0400 Subject: [PATCH 6/6] AUTH-7: fix mass-assignment, error leak, delete ordering, filename collision MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the "must-fix regardless of sequencing" findings from the AUTH-7 security review: - updateUser now destructures isAdmin explicitly instead of spreading the caller-supplied data object into Prisma, closing a mass-assignment path that could otherwise write unexpected fields (e.g. supabaseUserId). - getUsers now builds an explicit { isAdmin } where clause instead of passing the caller's filter object straight to Prisma's query builder. - getUser no longer returns raw Supabase Admin API error text to the caller; details are logged server-side instead. - deleteUser now runs the Prisma transaction before deleting the Supabase auth identity, so a failed transaction can't leave live session/ membership rows pointing at an identity that no longer resolves. - Renamed src/lib/supabase.ts to src/lib/supabase-admin.ts to avoid colliding with the src/lib/supabase/ directory added by #3 (auth-11), and added autoRefreshToken/persistSession: false plus clear env-var errors to match that module's admin client pattern. Caller-identity/authorization checks are intentionally deferred — this codebase has no merged session mechanism yet (the only one, #3, is still open) — and are tracked via a TODO in users.ts pending that follow-up. Co-Authored-By: Claude Sonnet 5 --- src/lib/supabase-admin.ts | 28 ++++++++++++++++++++++++++++ src/lib/supabase.ts | 17 ----------------- src/lib/users.ts | 32 ++++++++++++++++++++------------ 3 files changed, 48 insertions(+), 29 deletions(-) create mode 100644 src/lib/supabase-admin.ts delete mode 100644 src/lib/supabase.ts diff --git a/src/lib/supabase-admin.ts b/src/lib/supabase-admin.ts new file mode 100644 index 0000000..ddefcd6 --- /dev/null +++ b/src/lib/supabase-admin.ts @@ -0,0 +1,28 @@ +import "server-only"; +import { createClient } from "@supabase/supabase-js"; + +function requireEnv(name: string): string { + const value = process.env[name]; + if (!value) { + throw new Error(`Missing ${name} environment variable`); + } + return value; +} + +function createAdminClient() { + return createClient( + requireEnv("NEXT_PUBLIC_SUPABASE_URL"), + requireEnv("SUPABASE_SERVICE_ROLE_KEY"), + { auth: { autoRefreshToken: false, persistSession: false } }, + ); +} + +const globalForSupabase = globalThis as unknown as { + supabase?: ReturnType; +}; + +export const supabaseAdmin = globalForSupabase.supabase ?? createAdminClient(); + +if (process.env.NODE_ENV !== "production") { + globalForSupabase.supabase = supabaseAdmin; +} diff --git a/src/lib/supabase.ts b/src/lib/supabase.ts deleted file mode 100644 index 718a5c3..0000000 --- a/src/lib/supabase.ts +++ /dev/null @@ -1,17 +0,0 @@ -import "server-only"; -import { createClient } from "@supabase/supabase-js"; - -const globalForSupabase = globalThis as unknown as { - supabase?: ReturnType; -}; - -export const supabaseAdmin = - globalForSupabase.supabase ?? - createClient( - process.env.NEXT_PUBLIC_SUPABASE_URL!, - process.env.SUPABASE_SERVICE_ROLE_KEY!, - ); - -if (process.env.NODE_ENV !== "production") { - globalForSupabase.supabase = supabaseAdmin; -} diff --git a/src/lib/users.ts b/src/lib/users.ts index 1bfcbd6..19e07d1 100644 --- a/src/lib/users.ts +++ b/src/lib/users.ts @@ -1,7 +1,12 @@ "use server"; +// TODO(auth): none of these actions check caller identity/authority yet. +// Add an authorization check (self-access or User.isAdmin) once session +// infra lands (see #3 / auth-11's createServerSupabaseClient) — track in +// a follow-up ticket before this is wired into any client component. + import { prisma } from "@/lib/prisma"; -import { supabaseAdmin } from "@/lib/supabase"; +import { supabaseAdmin } from "@/lib/supabase-admin"; import type { User } from "@/generated/prisma/client"; /** @@ -45,7 +50,8 @@ export async function getUser( ); if (error) { - throw new Error(error.message); + console.error("Failed to fetch Supabase auth user email", error); + throw new Error("Failed to fetch user email"); } return { ...user, email: data.user.email }; @@ -60,7 +66,7 @@ export async function getUsers(filters?: { isAdmin?: boolean; }): Promise { return prisma.user.findMany({ - where: filters, + where: { isAdmin: filters?.isAdmin }, orderBy: { createdAt: "desc" }, }); } @@ -75,12 +81,13 @@ export async function updateUser( id: string, data: { isAdmin?: boolean }, ): Promise { - return prisma.user.update({ where: { id }, data }); + const { isAdmin } = data; + return prisma.user.update({ where: { id }, data: { isAdmin } }); } /** - * Deletes a User by ID. Attempts to delete the linked Supabase auth user first, - * then hard deletes all related data in a transaction + * Deletes a User by ID. Hard deletes all related data in a transaction first, + * then deletes the linked Supabase auth user. * @param id User UUID to delete * @throws If the User is not found, or the Supabase auth user deletion fails */ @@ -91,17 +98,18 @@ export async function deleteUser(id: string): Promise { throw new Error("User not found"); } + await prisma.$transaction([ + prisma.session.deleteMany({ where: { userId: id } }), + prisma.userProject.deleteMany({ where: { userId: id } }), + prisma.user.delete({ where: { id } }), + ]); + const { error } = await supabaseAdmin.auth.admin.deleteUser( user.supabaseUserId, ); if (error) { + console.error("Failed to delete Supabase auth user", error); throw new Error("Failed to delete Supabase auth user"); } - - await prisma.$transaction([ - prisma.session.deleteMany({ where: { userId: id } }), - prisma.userProject.deleteMany({ where: { userId: id } }), - prisma.user.delete({ where: { id } }), - ]); }