feat(db+auth): add pg pool, admin_users email/provider migration, refactor auth lookup

- Create src/lib/db.ts (shared pg.Pool, query/withTx helpers, server-only)
- Add @types/pg devDep
- Migration 204: add email, auth_provider, auth_subject columns to
  admin_users; backfill from auth.users; new upsert_admin_user accepts
  multi-provider args; new get_admin_user_for_session RPC resolves
  Auth.js session id (UUID or Google sub) to an admin row
- Refactor getAdminUser() to use pg + new RPC; auto-provisions on first
  Google sign-in using session.user.email
- Refactor updatePasswordAction to call update_user_password via pg
  (drops the Supabase REST hop)
- Delete orphaned src/actions/login.ts (replaced by auth-actions.ts)
- Drop remaining DEV_FORCE_UID references in users.ts; dev path now
  uses dev_session cookie (the only cookie the dev flow can set)
- Update AdminUser type: user_id is now string | null (Google users
  have no Supabase auth id); add email + auth_provider fields
- Fix downstream type errors: StopProductAssignment.callerUid accepts
  null; pickup.ts performedBy widens to string | null
- Bump vitest config, tests/, and other earlier cleanup changes
This commit is contained in:
2026-06-06 23:41:41 +00:00
parent 9374e63ae6
commit f96dcd01f2
53 changed files with 1837 additions and 1656 deletions
+130
View File
@@ -0,0 +1,130 @@
import "server-only";
/**
* Auth.js (NextAuth v5) configuration.
*
* Providers:
* - Google OAuth (real, primary; only active when AUTH_GOOGLE_ID + AUTH_GOOGLE_SECRET are set)
* - Credentials (email/password, wraps the existing Supabase auth flow so the login
* page keeps working during the cutover. Will be removed when Supabase auth is gone.)
*
* Session strategy: JWT. No database adapter — admin user lookup is handled by
* the existing SECURITY DEFINER RPCs + Supabase REST in `getAdminUser()`.
*
* Required env vars (production):
* - AUTH_SECRET — JWT signing secret
* - AUTH_URL — base URL (auto-detected on Vercel)
* - AUTH_GOOGLE_ID — Google OAuth client id
* - AUTH_GOOGLE_SECRET — Google OAuth client secret
*
* Backward compatibility: the legacy `rc_auth_uid` cookie and `dev_session` cookie
* are still read by `src/lib/admin-permissions.ts` (via `getAdminUser()`) and the
* middleware, so the dev/demo flow keeps working. New code should call `auth()`
* from this file instead of reading cookies directly.
*/
import NextAuth, { type DefaultSession } from "next-auth";
import Google from "next-auth/providers/google";
import Credentials from "next-auth/providers/credentials";
declare module "next-auth" {
interface Session {
user: {
id: string;
} & DefaultSession["user"];
}
}
const hasGoogleCreds = !!(
process.env.AUTH_GOOGLE_ID && process.env.AUTH_GOOGLE_SECRET
);
// Google provider is only added when both env vars are set so the build
// doesn't fail on hosts where Google isn't configured yet.
const googleProvider = hasGoogleCreds
? [
Google({
clientId: process.env.AUTH_GOOGLE_ID,
clientSecret: process.env.AUTH_GOOGLE_SECRET,
}),
]
: [];
// Credentials provider wraps the existing Supabase email/password flow.
// It returns a user with `id` = Supabase auth user id, which `getAdminUser()`
// then uses to look up `admin_users.user_id`. The JWT persists `id` and `email`.
const credentialsProvider = [
Credentials({
id: "supabase-password",
name: "Email and password",
credentials: {
email: { label: "Email", type: "email" },
password: { label: "Password", type: "password" },
},
async authorize(creds) {
const email = typeof creds?.email === "string" ? creds.email.trim() : "";
const password = typeof creds?.password === "string" ? creds.password : "";
if (!email || !password) return null;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
if (!supabaseUrl || !supabaseAnonKey) return null;
try {
const res = await fetch(
`${supabaseUrl}/auth/v1/token?grant_type=password`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
apikey: supabaseAnonKey,
},
body: JSON.stringify({ email, password }),
}
);
if (!res.ok) return null;
const data = (await res.json().catch(() => null)) as
| { user?: { id?: string; email?: string }; access_token?: string }
| null;
const userId = data?.user?.id;
if (!userId) return null;
return {
id: userId,
email: data?.user?.email ?? email,
name: data?.user?.email ?? email,
};
} catch {
return null;
}
},
}),
];
export const { handlers, auth, signIn, signOut } = NextAuth({
trustHost: true,
providers: [...googleProvider, ...credentialsProvider],
session: { strategy: "jwt" },
pages: {
signIn: "/login",
},
callbacks: {
async jwt({ token, user }) {
if (user) {
// user.id comes from the provider's authorize() return (Supabase user id)
// or from Google's `sub` claim for Google sign-ins.
if (user.id) token.id = user.id;
if (user.email) token.email = user.email;
}
return token;
},
async session({ session, token }) {
if (session.user) {
session.user.id =
(typeof token.id === "string" && token.id) ||
(typeof token.sub === "string" && token.sub) ||
"";
}
return session;
},
},
});