feat: remove dev_session, add Drizzle schema + RLS + real auth
BREAKING: dev_session cookie bypass removed. Admin access now requires a real Auth.js v5 session (Google OAuth in production). Provision users by inserting into users + tenant_users tables. New in this commit: - db/migrations/0001_init.sql: 18-table SaaS schema with RLS (tenants, users, tenant_users, plans, add_ons, subscriptions, tenant_add_ons, products, product_images, stops, customers, orders, order_items, brand_settings, email_templates, campaigns, files, audit_log) - db/schema/: Drizzle TypeScript mirror of every table - db/client.ts: withTenant() / withPlatformAdmin() query wrappers that set Postgres GUCs (app.current_tenant_id, app.platform_admin) for RLS enforcement. Never query a tenant-scoped table without one. - db/seed.ts: seeds 3 plans, 6 add-ons, 2 tenants (Tuxedo, Indian River Direct), brand_settings, sample products/stops/customers - scripts/migrate.js: applies migrations in lexical order with tracking - scripts/db-reset.js: drops + recreates DB, runs migrate + seed - DATABASE_URL now uses rc_app (non-superuser, NOBYPASSRLS). RLS is enforced even for the app user. DATABASE_ADMIN_URL for migrations. - src/lib/admin-permissions.ts: getAdminUser() reads Auth.js session, looks up user + tenant in Postgres. brand_id kept as alias for backward compat. - src/middleware.ts: Auth.js-only route protection, dev_session gone - src/app/login/LoginClient.tsx: Google OAuth only, no demo mode - src/components/admin/AdminSidebar.tsx + AdminHeader.tsx: signOutAction replaces supabase signout - @/db/* path aliases in tsconfig.json + vitest.config.ts - drizzle.config.ts added - db/auth_schema.sql removed (was a stub; replaced by real schema) - src/app/api/dev-login/route.ts deleted - tests: updated to remove dev_session coverage
This commit is contained in:
+19
-65
@@ -4,12 +4,18 @@ 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.)
|
||||
* - Google OAuth — only active when AUTH_GOOGLE_ID + AUTH_GOOGLE_SECRET
|
||||
* are set.
|
||||
*
|
||||
* Session strategy: JWT. No database adapter — admin user lookup is handled by
|
||||
* the existing SECURITY DEFINER RPCs + Supabase REST in `getAdminUser()`.
|
||||
* Supabase is no longer used for auth (or anything else) on this platform.
|
||||
* The historical Supabase-backed Credentials provider was removed in the
|
||||
* cleanup pass. New admin users are provisioned manually by an existing
|
||||
* platform admin via /admin/users (the action creates an `admin_users`
|
||||
* row linked to the Google `sub` after the user signs in for the first
|
||||
* time).
|
||||
*
|
||||
* Session strategy: JWT. No database adapter — admin user lookup is
|
||||
* delegated to `getAdminUser()` in `src/lib/admin-permissions.ts`.
|
||||
*
|
||||
* Required env vars (production):
|
||||
* - AUTH_SECRET — JWT signing secret
|
||||
@@ -17,15 +23,15 @@ import "server-only";
|
||||
* - 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.
|
||||
* Backward compatibility: the `dev_session` cookie was the source of
|
||||
* truth for the demo flow but has been removed — `getAdminUser()` and
|
||||
* the middleware now use only the Auth.js session. The legacy
|
||||
* `rc_auth_uid` cookie was retired earlier — see the
|
||||
* final report for the cleanup notes.
|
||||
*/
|
||||
|
||||
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 {
|
||||
@@ -39,8 +45,6 @@ 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({
|
||||
@@ -50,59 +54,9 @@ const googleProvider = hasGoogleCreds
|
||||
]
|
||||
: [];
|
||||
|
||||
// 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],
|
||||
providers: googleProvider,
|
||||
session: { strategy: "jwt" },
|
||||
pages: {
|
||||
signIn: "/login",
|
||||
@@ -110,8 +64,8 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
|
||||
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.
|
||||
// `user.id` is the provider's stable subject — for Google sign-ins
|
||||
// this is the opaque `sub` claim.
|
||||
if (user.id) token.id = user.id;
|
||||
if (user.email) token.email = user.email;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user