From 5654ebaecde0e2aeecd84a6dfd3c3532ebab057f Mon Sep 17 00:00:00 2001 From: default Date: Sat, 6 Jun 2026 22:13:56 +0000 Subject: [PATCH] chore(auth): remove legacy rc_auth_uid/rc_uid/upsert_admin_user path; scope sign-in to ADMIN_ALLOWED_EMAILS; silence lockfile warning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cleanup after Auth.js v5 became the only sign-in path. The platform had three overlapping auth modes (dev cookie, legacy rc_auth_uid, Auth.js JWT) and a pile of dead-code pages/routes that only existed to support the legacy path. What changed: * getAdminUser() now has only two auth paths: 1. dev_session cookie (auto-issued by src/proxy.ts for /admin/* when ALLOW_DEV_LOGIN is enabled) 2. Auth.js v5 JWT (the encrypted cookie + auth() lookup) The legacy rc_auth_uid/rc_uid branch and the Supabase REST fetch against admin_users are gone. * The signIn callback in src/lib/auth.ts enforces ADMIN_ALLOWED_EMAILS when set. Unset = open mode (backward compatible with demo/dev). Dev credentials provider is exempt. The new env var is wired through .env.example and .gitea/workflows/deploy.yml (read from secrets.ADMIN_ALLOWED_EMAILS, written to the server .env file). * change-password/page.tsx now uses auth() server-side instead of fetching the deleted /api/auth/uid endpoint. The form is split into page.tsx (server component, auth check) + ChangePasswordForm.tsx (client component, form state). updatePasswordAction now reads the user id from auth() instead of the rc_auth_uid cookie. * Deleted 14 dead-code files: - Pages: login2, logout, auth/callback, admin/debug-auth, admin/test-auth - API routes: api/login, api/logout, api/auth/uid, api/force-admin, api/set-auth-cookie, api/debug-cookie, api/debug-me, api/debug-auth - Actions: src/actions/login.ts These were the old email/password login, the old Supabase OAuth callback, the old /api/auth/uid probe, and a pile of debug endpoints that have been superseded by the new proxy + the new /login page. * next.config.ts: set outputFileTracingRoot: '.' to silence the Next.js 16 lockfile-inference warning. Without this the build walked up from package.json looking for a lockfile, found the homelab runner's stale act cache at /home/tyler/.cache/act/.../package-lock.json, and warned on every build. '. resolves to the project root in both dev and CI, so it's the right answer. Out of scope (deferred): * src/actions/admin/users.ts still uses rc_auth_uid internally for its dev-bypass logic. It works (the rc_auth_uid branch is gated on NODE_ENV != 'production' and DEV_FORCE_UID), but it's now genuinely unreachable in production. Clean up in a follow-up. Pre-flight: * npx tsc --noEmit: clean * npm run lint (touched files): clean * npm run build: clean — proxy picked up, no lockfile warning, all 93 static pages generated. --- .env.example | 7 + .gitea/workflows/deploy.yml | 3 + next.config.ts | 11 ++ src/actions/admin/password.ts | 24 ++- src/actions/login.ts | 56 ------ src/app/admin/debug-auth/page.tsx | 60 ------- src/app/admin/test-auth/page.tsx | 90 ---------- src/app/api/auth/uid/route.ts | 11 -- src/app/api/debug-auth/route.ts | 48 ----- src/app/api/debug-cookie/route.ts | 18 -- src/app/api/debug-me/route.ts | 80 --------- src/app/api/force-admin/route.ts | 31 ---- src/app/api/login/route.ts | 47 ----- src/app/api/logout/route.ts | 12 -- src/app/api/set-auth-cookie/route.ts | 22 --- src/app/auth/callback/page.tsx | 54 ------ .../change-password/ChangePasswordForm.tsx | 120 +++++++++++++ src/app/change-password/page.tsx | 168 +++--------------- src/app/login2/page.tsx | 105 ----------- src/app/logout/page.tsx | 41 ----- src/lib/admin-permissions.ts | 95 +--------- src/lib/auth.ts | 42 +++++ 22 files changed, 229 insertions(+), 916 deletions(-) delete mode 100644 src/actions/login.ts delete mode 100644 src/app/admin/debug-auth/page.tsx delete mode 100644 src/app/admin/test-auth/page.tsx delete mode 100644 src/app/api/auth/uid/route.ts delete mode 100644 src/app/api/debug-auth/route.ts delete mode 100644 src/app/api/debug-cookie/route.ts delete mode 100644 src/app/api/debug-me/route.ts delete mode 100644 src/app/api/force-admin/route.ts delete mode 100644 src/app/api/login/route.ts delete mode 100644 src/app/api/logout/route.ts delete mode 100644 src/app/api/set-auth-cookie/route.ts delete mode 100644 src/app/auth/callback/page.tsx create mode 100644 src/app/change-password/ChangePasswordForm.tsx delete mode 100644 src/app/login2/page.tsx delete mode 100644 src/app/logout/page.tsx diff --git a/.env.example b/.env.example index 80019c6..8b72504 100644 --- a/.env.example +++ b/.env.example @@ -40,6 +40,13 @@ GOOGLE_CLIENT_SECRET= # development. Default: enabled in dev only. ALLOW_DEV_LOGIN=true +# Comma-separated list of email addresses allowed to sign in via Google +# OAuth. If unset (or empty), any Google account can sign in and gets a +# `platform_admin` row auto-created — fine for demo/dev. Set this in +# production to lock sign-in down to a known set of admins. +# Example: ADMIN_ALLOWED_EMAILS=tyler@example.com,sarah@example.com +ADMIN_ALLOWED_EMAILS= + # ── Supabase (legacy, being removed) ──────────────────────────────────────── # Still used by the existing admin pages, server actions, and the # `getAdminUser` flow. Once the auth migration is complete and the diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index 8a65740..fc78256 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -169,6 +169,7 @@ jobs: GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID || secrets.AUTH_GOOGLE_ID }} GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET || secrets.AUTH_GOOGLE_SECRET }} ALLOW_DEV_LOGIN: ${{ secrets.ALLOW_DEV_LOGIN }} + ADMIN_ALLOWED_EMAILS: ${{ secrets.ADMIN_ALLOWED_EMAILS }} # Supabase (legacy, still used by admin pages/server actions until # the Auth.js migration is finished) @@ -221,6 +222,7 @@ jobs: GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID || secrets.AUTH_GOOGLE_ID }} GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET || secrets.AUTH_GOOGLE_SECRET }} ALLOW_DEV_LOGIN: ${{ secrets.ALLOW_DEV_LOGIN }} + ADMIN_ALLOWED_EMAILS: ${{ secrets.ADMIN_ALLOWED_EMAILS }} # Storage STORAGE_ENDPOINT: ${{ secrets.STORAGE_ENDPOINT }} @@ -277,6 +279,7 @@ jobs: printf "GOOGLE_CLIENT_ID=%s\n" "$GOOGLE_CLIENT_ID" printf "GOOGLE_CLIENT_SECRET=%s\n" "$GOOGLE_CLIENT_SECRET" printf "ALLOW_DEV_LOGIN=%s\n" "$ALLOW_DEV_LOGIN" + printf "ADMIN_ALLOWED_EMAILS=%s\n" "$ADMIN_ALLOWED_EMAILS" printf "STORAGE_ENDPOINT=%s\n" "$STORAGE_ENDPOINT" printf "STORAGE_REGION=%s\n" "$STORAGE_REGION" printf "STORAGE_ACCESS_KEY=%s\n" "$STORAGE_ACCESS_KEY" diff --git a/next.config.ts b/next.config.ts index 5b4263c..c23ed8f 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,6 +1,17 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { + // Lock the file-tracing root to the project directory. Without this, + // Next.js 16 walks up from package.json looking for a lockfile, finds + // the homelab runner's stale `act` cache at + // /home/tyler/.cache/act/.../package-lock.json, and warns: + // "We detected multiple lockfiles and selected the directory of + // /home/tyler/package-lock.json as the root directory." + // The deploy runner's APP_DIR is /home/tyler/route-commerce, so + // resolving relative to the project root is correct both locally and + // in CI. + outputFileTracingRoot: ".", + // Enable strict mode reactStrictMode: true, diff --git a/src/actions/admin/password.ts b/src/actions/admin/password.ts index 24e317e..f677fd9 100644 --- a/src/actions/admin/password.ts +++ b/src/actions/admin/password.ts @@ -1,18 +1,24 @@ "use server"; -import { cookies } from "next/headers"; +import { auth } from "@/lib/auth"; import { createClient as createServiceClient } from "@supabase/supabase-js"; +/** + * Update the password for the currently signed-in admin. + * + * Identity comes from the Auth.js session (`auth().user.id`), which is + * the same UUID space as `admin_users.user_id` and `auth.users.id` in + * Postgres. The legacy `rc_auth_uid` / `rc_uid` cookie fallback has + * been removed — the Auth.js JWT is the single source of truth. + */ export async function updatePasswordAction( newPassword: string ): Promise<{ error?: string }> { - const cookieStore = await cookies(); - const uid = - cookieStore.get("rc_auth_uid")?.value ?? - cookieStore.get("rc_uid")?.value; + const session = await auth(); + const userId = session?.user?.id; - if (!uid) { - return { error: "Not authenticated. Please log in again." }; + if (!userId) { + return { error: "Not authenticated. Please sign in again." }; } const service = createServiceClient( @@ -21,10 +27,10 @@ export async function updatePasswordAction( ); const { error } = await service.rpc("update_user_password", { - p_user_id: uid, + p_user_id: userId, p_password: newPassword, }); if (error) return { error: error.message }; return {}; -} \ No newline at end of file +} diff --git a/src/actions/login.ts b/src/actions/login.ts deleted file mode 100644 index da40bbc..0000000 --- a/src/actions/login.ts +++ /dev/null @@ -1,56 +0,0 @@ -"use server"; - -import { cookies } from "next/headers"; -import { createServerClient } from "@supabase/ssr"; - -export type LoginWithPasswordResult = - | { success: true; redirect: true } - | { success: false; error: string }; - -export async function loginWithPassword( - email: string, - password: string -): Promise { - const cookieStore = await cookies(); - - const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL; - const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY; - - if (!supabaseUrl || !supabaseAnonKey) { - return { success: false, error: "Server misconfiguration." }; - } - - const supabase = createServerClient(supabaseUrl, supabaseAnonKey, { - cookies: { - getAll() { - return cookieStore.getAll(); - }, - setAll(cookiesToSet) { - cookiesToSet.forEach(({ name, value, options }) => { - cookieStore.set(name, value, options); - }); - }, - }, - }); - - const { data, error } = await supabase.auth.signInWithPassword({ - email, - password, - }); - - if (error || !data.user) { - return { success: false, error: error?.message || "Invalid credentials" }; - } - - // Set the rc_auth_uid cookie that getAdminUser() reads - const isProd = process.env.NODE_ENV === "production"; - cookieStore.set("rc_auth_uid", data.user.id, { - path: "/", - maxAge: 60 * 60 * 24 * 30, - httpOnly: true, - sameSite: "lax", - secure: isProd, - }); - - return { success: true, redirect: true }; -} diff --git a/src/app/admin/debug-auth/page.tsx b/src/app/admin/debug-auth/page.tsx deleted file mode 100644 index f949329..0000000 --- a/src/app/admin/debug-auth/page.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import { cookies } from "next/headers"; - -export default async function DebugAuthPage() { - const cookieStore = await cookies(); - const allCookies = cookieStore.getAll(); - const rcAuthUid = cookieStore.get("rc_auth_uid")?.value; - const rcUid = cookieStore.get("rc_uid")?.value; - const rcAccessToken = cookieStore.get("rc_access_token")?.value; - - let adminUsersStatus = "not_tried"; - let adminUsersResult: string | null = null; - - if (rcAuthUid) { - const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL; - const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY; - if (supabaseUrl && serviceKey) { - try { - const res = await fetch( - `${supabaseUrl}/rest/v1/admin_users?user_id=eq.${rcAuthUid}&limit=1`, - { headers: { apikey: serviceKey, "Content-Type": "application/json" } } - ); - adminUsersStatus = String(res.status); - const data = await res.json().catch(() => null); - adminUsersResult = JSON.stringify(data); - } catch (e) { - adminUsersStatus = "error: " + (e instanceof Error ? e.message : String(e)); - } - } else { - adminUsersStatus = "missing_env_vars"; - } - } else { - adminUsersStatus = "no_rc_auth_uid_cookie"; - } - - return ( -
-

Auth Debug

- -
-

Cookies ({allCookies.length})

-
-          {allCookies.map(c => `${c.name}=${c.value.slice(0, 80)}${c.value.length > 80 ? "..." : ""}`).join("\n") || "(none)"}
-        
-
- -
-

Key Cookies

-

rc_auth_uid: {rcAuthUid ? `${rcAuthUid.slice(0, 20)}...` : "NOT SET"}

-

rc_uid: {rcUid ? `${rcUid.slice(0, 20)}...` : "NOT SET"}

-

rc_access_token: {rcAccessToken ? "PRESENT" : "NOT SET"}

-
- -
-

Admin Users Lookup

-

Status: {adminUsersStatus}

-

Result:

{adminUsersResult || "(none)"}

-
-
- ); -} \ No newline at end of file diff --git a/src/app/admin/test-auth/page.tsx b/src/app/admin/test-auth/page.tsx deleted file mode 100644 index cdbaafb..0000000 --- a/src/app/admin/test-auth/page.tsx +++ /dev/null @@ -1,90 +0,0 @@ -import { getAdminUser } from "@/lib/admin-permissions"; -import { cookies } from "next/headers"; - -export const dynamic = "force-dynamic"; - -export default async function TestAuthPage() { - const cookieStore = await cookies(); - const allCookies = cookieStore.getAll(); - - let adminUser = null; - let error: string | null = null; - - try { - adminUser = await getAdminUser(); - } catch (e: unknown) { - error = e instanceof Error ? e.message : String(e); - } - - return ( -
-

Auth Debug

- -
-

All Cookies ({allCookies.length})

-
-          {allCookies.map(c => `${c.name}=${c.value.slice(0, 80)}${c.value.length > 80 ? "..." : ""}`).join("\n") || "(none)"}
-        
-
- -
-

rc_auth_uid

-

- {allCookies.some(c => c.name === "rc_auth_uid") - ? SET — {allCookies.find(c => c.name === "rc_auth_uid")?.value} - : NOT SET - } -

-
- -
-

rc_access_token

-

- {allCookies.some(c => c.name === "rc_access_token") - ? SET (not needed) - : NOT SET (OK) - } -

-
- -
-

getAdminUser() result

- {error ? ( -
-

ERROR

-
{error}
-
- ) : adminUser ? ( -
-

AUTHENTICATED

-
-              {JSON.stringify({
-                id: adminUser.id,
-                user_id: adminUser.user_id,
-                role: adminUser.role,
-                brand_id: adminUser.brand_id,
-                active: adminUser.active,
-              }, null, 2)}
-            
-
- ) : ( -

NOT AUTHENTICATED — null returned

- )} -
- -
-

Quick Actions

-
- - Go to Admin - -
- -
-
-
-
- ); -} \ No newline at end of file diff --git a/src/app/api/auth/uid/route.ts b/src/app/api/auth/uid/route.ts deleted file mode 100644 index 9a96310..0000000 --- a/src/app/api/auth/uid/route.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { NextResponse } from "next/server"; -import { cookies } from "next/headers"; - -export async function GET() { - const cookieStore = await cookies(); - const uid = - cookieStore.get("rc_auth_uid")?.value ?? - cookieStore.get("rc_uid")?.value ?? - null; - return NextResponse.json({ uid }); -} \ No newline at end of file diff --git a/src/app/api/debug-auth/route.ts b/src/app/api/debug-auth/route.ts deleted file mode 100644 index 41ca183..0000000 --- a/src/app/api/debug-auth/route.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { NextResponse } from "next/server"; -import { cookies } from "next/headers"; - -export async function GET() { - const cookieStore = await cookies(); - const allCookies = cookieStore.getAll(); - const rcAuthUid = cookieStore.get("rc_auth_uid")?.value; - const rcUid = cookieStore.get("rc_uid")?.value; - const rcAccessToken = cookieStore.get("rc_access_token")?.value; - - let adminUsersStatus = "not_tried"; - let adminUsersResult: string | null = null; - - if (rcAuthUid) { - const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL; - const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY; - if (supabaseUrl && serviceKey) { - try { - const res = await fetch( - `${supabaseUrl}/rest/v1/admin_users?user_id=eq.${rcAuthUid}&limit=1`, - { headers: { apikey: serviceKey, "Content-Type": "application/json" } } - ); - adminUsersStatus = String(res.status); - const data = await res.json().catch(() => null); - adminUsersResult = JSON.stringify(data); - } catch (e) { - adminUsersStatus = "error: " + (e instanceof Error ? e.message : String(e)); - } - } else { - adminUsersStatus = "missing_env_vars"; - } - } else { - adminUsersStatus = "no_rc_auth_uid_cookie"; - } - - return NextResponse.json({ - cookies: { - rc_auth_uid: rcAuthUid ? `${rcAuthUid.slice(0, 10)}...` : null, - rc_uid: rcUid ? `${rcUid.slice(0, 10)}...` : null, - rc_access_token: rcAccessToken ? "present" : null, - all_cookie_names: allCookies.map(c => c.name), - }, - admin_users: { - status: adminUsersStatus, - result: adminUsersResult, - }, - }); -} \ No newline at end of file diff --git a/src/app/api/debug-cookie/route.ts b/src/app/api/debug-cookie/route.ts deleted file mode 100644 index eed9951..0000000 --- a/src/app/api/debug-cookie/route.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { NextResponse } from "next/server"; -import { cookies } from "next/headers"; - -export const dynamic = "force-dynamic"; - -export async function GET() { - const cookieStore = await cookies(); - const allCookies = cookieStore.getAll(); - const rcAuthUid = cookieStore.get("rc_auth_uid")?.value; - const rcUid = cookieStore.get("rc_uid")?.value; - - return NextResponse.json({ - received_cookies: allCookies.map(c => ({ name: c.name, value_len: c.value.length })), - rc_auth_uid: rcAuthUid ? `${rcAuthUid.slice(0, 8)}...` : "MISSING", - rc_uid: rcUid ? `${rcUid.slice(0, 8)}...` : "MISSING", - raw_rc_auth_uid: rcAuthUid ?? null, - }); -} diff --git a/src/app/api/debug-me/route.ts b/src/app/api/debug-me/route.ts deleted file mode 100644 index 72aa90b..0000000 --- a/src/app/api/debug-me/route.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { NextResponse } from "next/server"; -import { cookies } from "next/headers"; - -export const dynamic = "force-dynamic"; - -export async function GET() { - const cookieStore = await cookies(); - const uid = cookieStore.get("rc_auth_uid")?.value ?? cookieStore.get("rc_uid")?.value; - - if (!uid) { - return NextResponse.json({ error: "No uid cookie found" }, { status: 400 }); - } - - const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL; - const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY; - - if (!supabaseUrl || !serviceKey) { - return NextResponse.json({ - uid, - error: "Missing env vars", - supabaseUrl: supabaseUrl ?? "MISSING", - serviceKeyPresent: !!serviceKey, - }); - } - - // Exact same lookup as getAdminUser - const lookupRes = await fetch( - `${supabaseUrl}/rest/v1/admin_users?user_id=eq.${uid}&limit=1`, - { headers: { apikey: serviceKey, "Content-Type": "application/json" } } - ); - - let adminUsers: unknown[] = []; - let lookupOk = lookupRes.ok; - let lookupStatus = lookupRes.status; - let lookupData: unknown = null; - - if (lookupRes.ok) { - lookupData = await lookupRes.json().catch(() => []); - adminUsers = Array.isArray(lookupData) ? lookupData : []; - } else { - lookupData = await lookupRes.text().catch(() => "unknown error"); - } - - if (adminUsers.length > 0) { - return NextResponse.json({ - uid, - result: "found", - adminUser: adminUsers[0], - }); - } - - // Try auto-create - const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; - if (!UUID_REGEX.test(uid)) { - return NextResponse.json({ uid, result: "invalid_uuid" }); - } - - const postRes = await fetch(`${supabaseUrl}/rest/v1/admin_users`, { - method: "POST", - headers: { apikey: serviceKey, "Content-Type": "application/json", Prefer: "return=representation" }, - body: JSON.stringify({ - user_id: uid, role: "platform_admin", brand_id: null, active: true, - can_manage_products: true, can_manage_stops: true, can_manage_orders: true, - can_manage_pickup: true, can_manage_messages: true, can_manage_refunds: true, - can_manage_users: true, can_manage_water_log: true, can_manage_reports: true, - can_manage_settings: true, must_change_password: false - }), - }); - - return NextResponse.json({ - uid, - result: "auto_created", - lookupOk, - lookupStatus, - adminUsersFound: adminUsers.length, - postStatus: postRes.status, - postOk: postRes.ok, - postData: postRes.ok ? await postRes.json().catch(() => null) : await postRes.text().catch(() => null), - }); -} diff --git a/src/app/api/force-admin/route.ts b/src/app/api/force-admin/route.ts deleted file mode 100644 index 97b7de7..0000000 --- a/src/app/api/force-admin/route.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { NextResponse } from "next/server"; - -const DEV_ADMIN_UID = "dev-user-00000000-0000-0000-0000-000000000001"; -const DEV_ROLES = ["platform_admin", "brand_admin", "store_employee"]; - -export async function GET(request: Request) { - const url = new URL(request.url); - const role = url.searchParams.get("role") ?? "platform_admin"; - const safeRole = DEV_ROLES.includes(role) ? role : "platform_admin"; - - const origin = url.origin; - - const response = NextResponse.redirect(new URL("/admin", origin)); - - const cookieOptions = { - path: "/", - maxAge: 60 * 60 * 24 * 30, - sameSite: "lax" as const, - }; - - response.cookies.set("dev_session", safeRole, { - ...cookieOptions, - httpOnly: false, - }); - response.cookies.set("rc_auth_uid", DEV_ADMIN_UID, { - ...cookieOptions, - httpOnly: false, - }); - - return response; -} \ No newline at end of file diff --git a/src/app/api/login/route.ts b/src/app/api/login/route.ts deleted file mode 100644 index abfea1a..0000000 --- a/src/app/api/login/route.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { NextResponse } from "next/server"; -import { cookies } from "next/headers"; - -export async function POST(request: Request) { - const { email, password } = await request.json().catch(() => ({})); - - if (!email || !password) { - return NextResponse.json({ error: "Email and password are required." }, { status: 400 }); - } - - const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL; - const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY; - if (!supabaseUrl || !supabaseAnonKey) { - return NextResponse.json({ error: "Server misconfiguration." }, { status: 500 }); - } - - const authRes = await fetch(`${supabaseUrl}/auth/v1/token?grant_type=password`, { - method: "POST", - headers: { "Content-Type": "application/json", apikey: supabaseAnonKey }, - body: JSON.stringify({ email, password }), - }); - - const authData = await authRes.json().catch(() => null); - - if (!authRes.ok || !authData?.access_token) { - const msg = authData?.error_description ?? authData?.error ?? "Invalid credentials."; - return NextResponse.json({ ok: false, error: msg }, { status: 401 }); - } - - const userId = authData.user?.id ?? authData.user_id; - if (!userId) { - return NextResponse.json({ ok: false, error: "Auth succeeded but user ID missing." }, { status: 500 }); - } - - // Set cookie + return JSON — client reads this and navigates - const isProd = process.env.NODE_ENV === "production"; - const response = NextResponse.json({ ok: true }); - response.cookies.set("rc_auth_uid", userId, { - path: "/", - maxAge: 60 * 60 * 24 * 30, - httpOnly: true, - sameSite: "lax", - secure: isProd, - }); - - return response; -} \ No newline at end of file diff --git a/src/app/api/logout/route.ts b/src/app/api/logout/route.ts deleted file mode 100644 index 3846a1b..0000000 --- a/src/app/api/logout/route.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { NextResponse } from "next/server"; -import { cookies } from "next/headers"; - -export async function POST() { - const cookieStore = await cookies(); - // Clear all auth cookies (new + legacy) - cookieStore.delete("rc_access_token"); - cookieStore.delete("rc_uid"); - cookieStore.delete("rc_auth_uid"); - cookieStore.delete("rc_auth_token"); - return NextResponse.json({ success: true }); -} diff --git a/src/app/api/set-auth-cookie/route.ts b/src/app/api/set-auth-cookie/route.ts deleted file mode 100644 index b281a89..0000000 --- a/src/app/api/set-auth-cookie/route.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { NextResponse } from "next/server"; -import { cookies } from "next/headers"; - -export async function POST(request: Request) { - const { userId } = await request.json().catch(() => ({})); - - if (!userId) { - return NextResponse.json({ error: "Missing userId" }, { status: 400 }); - } - - const cookieStore = await cookies(); - const isProd = process.env.NODE_ENV === "production"; - cookieStore.set("rc_auth_uid", userId, { - path: "/", - maxAge: 60 * 60 * 24 * 30, - httpOnly: true, - sameSite: isProd ? "strict" : "lax", - secure: isProd, - }); - - return NextResponse.json({ ok: true }); -} \ No newline at end of file diff --git a/src/app/auth/callback/page.tsx b/src/app/auth/callback/page.tsx deleted file mode 100644 index 19ad3b4..0000000 --- a/src/app/auth/callback/page.tsx +++ /dev/null @@ -1,54 +0,0 @@ -"use client"; - -import { useEffect } from "react"; -import { useRouter, useSearchParams } from "next/navigation"; - -export default function AuthCallback() { - const router = useRouter(); - - useEffect(() => { - const url = new URL(window.location.href); - // Supabase sends token via query params (not hash) on redirect - const accessToken = url.searchParams.get("token") || url.searchParams.get("access_token"); - const type = url.searchParams.get("type"); - const error = url.searchParams.get("error"); - - if (error) { - router.replace(`/login?error=${error}`); - return; - } - - if (!accessToken) { - router.replace("/login?error=no_token"); - return; - } - - // Validate token by fetching user info from Supabase - fetch(`${process.env.NEXT_PUBLIC_SUPABASE_URL}/auth/v1/user`, { - headers: { - apikey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, - Authorization: `Bearer ${accessToken}`, - }, - }) - .then(r => r.json()) - .then(data => { - if (data?.id) { - // Set rc_auth_uid cookie via API route - return fetch("/api/set-auth-cookie", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ userId: data.id }), - }); - } - throw new Error("No user ID in response"); - }) - .then(() => router.replace("/admin")) - .catch(() => router.replace("/login?error=token_invalid")); - }, [router]); - - return ( -
-
Verifying reset link...
-
- ); -} \ No newline at end of file diff --git a/src/app/change-password/ChangePasswordForm.tsx b/src/app/change-password/ChangePasswordForm.tsx new file mode 100644 index 0000000..ce5b4b8 --- /dev/null +++ b/src/app/change-password/ChangePasswordForm.tsx @@ -0,0 +1,120 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { updatePasswordAction } from "@/actions/admin/password"; +import { logUserActivity } from "@/actions/admin/audit"; + +export default function ChangePasswordForm({ userId }: { userId: string }) { + const router = useRouter(); + const [password, setPassword] = useState(""); + const [confirm, setConfirm] = useState(""); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setError(null); + + if (password.length < 8) { + setError("Password must be at least 8 characters."); + return; + } + if (password !== confirm) { + setError("Passwords do not match."); + return; + } + + setLoading(true); + const result = await updatePasswordAction(password); + setLoading(false); + + if (result.error) { + setError(result.error); + return; + } + + logUserActivity({ + user_id: userId, + activity_type: "password_change", + details: {}, + }).catch(() => {}); + + router.push("/admin"); + router.refresh(); + } + + return ( +
+
+
+
+ + + +
+

Change Password

+

+ You must set a new password before continuing. +

+ +
+ {error && ( +
+ {error} +
+ )} + +
+ + setPassword(e.target.value)} + required + minLength={8} + className="w-full rounded-xl border border-zinc-700 bg-zinc-800 px-4 py-3 text-zinc-100 placeholder:text-zinc-600 outline-none focus:border-violet-500 transition-colors" + placeholder="Min. 8 characters" + autoFocus + /> +
+ +
+ + setConfirm(e.target.value)} + required + className="w-full rounded-xl border border-zinc-700 bg-zinc-800 px-4 py-3 text-zinc-100 placeholder:text-zinc-600 outline-none focus:border-violet-500 transition-colors" + placeholder="Repeat password" + /> +
+ + +
+
+
+
+ ); +} diff --git a/src/app/change-password/page.tsx b/src/app/change-password/page.tsx index ea8a5f4..0059fe1 100644 --- a/src/app/change-password/page.tsx +++ b/src/app/change-password/page.tsx @@ -1,150 +1,28 @@ -"use client"; +import { redirect } from "next/navigation"; +import { auth } from "@/lib/auth"; +import ChangePasswordForm from "./ChangePasswordForm"; -import { useEffect, useState } from "react"; -import { useRouter } from "next/navigation"; -import Link from "next/link"; -import { updatePasswordAction } from "@/actions/admin/password"; -import { logUserActivity } from "@/actions/admin/audit"; +export const dynamic = "force-dynamic"; -export default function ChangePasswordPage() { - const router = useRouter(); - const [password, setPassword] = useState(""); - const [confirm, setConfirm] = useState(""); - const [error, setError] = useState(null); - const [loading, setLoading] = useState(false); - const [checkingSession, setCheckingSession] = useState(true); - const [userId, setUserId] = useState(null); +/** + * Forced password-change page. + * + * Linked from `src/app/admin/layout.tsx:64` when the admin user's row + * has `must_change_password = true`. Verifies the Auth.js session + * server-side, then renders the form with the user id passed as a prop. + * + * Previously this page fetched `/api/auth/uid` from a `useEffect` to + * resolve the current user. That endpoint (and the underlying + * `rc_auth_uid` cookie) has been removed — the Auth.js JWT is the + * single source of truth for identity now. + */ +export default async function ChangePasswordPage() { + const session = await auth(); + const userId = session?.user?.id; - useEffect(() => { - fetch("/api/auth/uid") - .then((r) => r.json()) - .then((data) => { - if (!data.uid) { - router.push("/login"); - } else { - setUserId(data.uid); - setCheckingSession(false); - } - }) - .catch(() => router.push("/login")); - }, [router]); - - async function handleSubmit(e: React.FormEvent) { - e.preventDefault(); - setError(null); - - if (password.length < 8) { - setError("Password must be at least 8 characters."); - return; - } - if (password !== confirm) { - setError("Passwords do not match."); - return; - } - - setLoading(true); - const result = await updatePasswordAction(password); - setLoading(false); - - if (result.error) { - setError(result.error); - return; - } - - if (userId) { - logUserActivity({ - user_id: userId, - activity_type: "password_change", - details: {}, - }); - } - - router.push("/admin"); - router.refresh(); + if (!userId) { + redirect("/login"); } - if (checkingSession) { - return ( -
-
-
-

Checking session...

-
-
-
- ); - } - - return ( -
-
-
-
- - - -
-

Change Password

-

- You must set a new password before continuing. -

- -
- {error && ( -
- {error} -
- )} - -
- - setPassword(e.target.value)} - required - minLength={8} - className="w-full rounded-xl border border-zinc-700 bg-zinc-800 px-4 py-3 text-zinc-100 placeholder:text-zinc-600 outline-none focus:border-violet-500 transition-colors" - placeholder="Min. 8 characters" - autoFocus - /> -
- -
- - setConfirm(e.target.value)} - required - className="w-full rounded-xl border border-zinc-700 bg-zinc-800 px-4 py-3 text-zinc-100 placeholder:text-zinc-600 outline-none focus:border-violet-500 transition-colors" - placeholder="Repeat password" - /> -
- - -
- -
- - Sign out instead - -
-
-
-
- ); -} \ No newline at end of file + return ; +} diff --git a/src/app/login2/page.tsx b/src/app/login2/page.tsx deleted file mode 100644 index 2aaaf5d..0000000 --- a/src/app/login2/page.tsx +++ /dev/null @@ -1,105 +0,0 @@ -"use client"; - -import { useState } from "react"; -import Link from "next/link"; - -export default function LoginPage2() { - const [email, setEmail] = useState(""); - const [password, setPassword] = useState(""); - const [error, setError] = useState(null); - const [loading, setLoading] = useState(false); - - async function handleSubmit(e: React.FormEvent) { - e.preventDefault(); - setError(null); - - if (!email.trim()) { setError("Email is required."); return; } - if (!password.trim()) { setError("Password is required."); return; } - - setLoading(true); - - const res = await fetch("/api/login", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ email: email.trim(), password }), - }); - - setLoading(false); - - if (res.status === 303) { - window.location.href = "/admin"; - } else { - const data = await res.json().catch(() => ({ error: "Login failed" })); - setError(data.error || "Login failed."); - } - } - - return ( -
-
-
-

Admin Login

-

Sign in to your account.

- -
- {error && ( -
- {error} -
- )} - -
- - setEmail(e.target.value)} - required - autoComplete="username" - disabled={loading} - className="mt-1 w-full rounded-xl border border-slate-300 px-4 py-3 outline-none focus:border-slate-900 disabled:bg-slate-100 disabled:cursor-not-allowed" - placeholder="admin@example.com" - /> -
- -
- - setPassword(e.target.value)} - required - autoComplete="current-password" - disabled={loading} - className="mt-1 w-full rounded-xl border border-slate-300 px-4 py-3 outline-none focus:border-slate-900 disabled:bg-slate-100 disabled:cursor-not-allowed" - placeholder="••••••••" - /> -
- - -
-
- - - ← Back to storefront - -
-
- ); -} \ No newline at end of file diff --git a/src/app/logout/page.tsx b/src/app/logout/page.tsx deleted file mode 100644 index a2dea5f..0000000 --- a/src/app/logout/page.tsx +++ /dev/null @@ -1,41 +0,0 @@ -"use client"; - -import { useEffect } from "react"; -import { useRouter } from "next/navigation"; -import { supabase } from "@/lib/supabase"; - -export default function LogoutPage() { - const router = useRouter(); - - useEffect(() => { - // Clear all auth cookies — dev_session, rc_auth_uid, rc_auth_token - document.cookie = "dev_session=;path=/;max-age=0"; - document.cookie = "rc_auth_uid=;path=/;max-age=0"; - document.cookie = "rc_auth_token=;path=/;max-age=0"; - // Clear shopping cart on logout - localStorage.removeItem("route_commerce_cart"); - localStorage.removeItem("route_commerce_stop"); - - // Sign out from Supabase and clear server cart - supabase.auth.getUser().then(async ({ data }) => { - if (data.user?.id) { - const { clearServerCart } = await import("@/actions/checkout"); - clearServerCart(data.user.id).catch(() => {}); - } - supabase.auth.signOut().then(() => { - router.push("/login"); - router.refresh(); - }); - }); - }, [router]); - - return ( -
-
-
-

Signing out...

-
-
-
- ); -} diff --git a/src/lib/admin-permissions.ts b/src/lib/admin-permissions.ts index cd1ea9c..9b360af 100644 --- a/src/lib/admin-permissions.ts +++ b/src/lib/admin-permissions.ts @@ -12,13 +12,17 @@ export type { AdminUser } from "./admin-permissions-types"; * 2. `dev_session` cookie → dev admin (platform_admin/brand_admin/store_employee). * 3. Auth.js v5 session (JWT cookie) → look up `admin_users` by the * Auth.js user id (the `users.id` UUID managed by @auth/pg-adapter). - * 4. Real auth (rc_auth_uid or rc_uid cookie) → load admin_users + brand_ids. + * + * The legacy `rc_auth_uid` / `rc_uid` cookie path has been removed. + * The Auth.js JWT is the single source of truth for identity; the + * `dev_session` cookie is only used for the demo / dev auto-login in + * `src/proxy.ts`. * * `brand_id` is the active brand; `brand_ids` is the full membership list. * For dev sessions without a real DB, `brand_ids` is populated by: * - platform_admin: `[]` (listBrandsForAdmin resolves against the brands table) - * - store_employee: `[]` if a brand exists, else `[]` - * - brand_admin: `[]` (legacy dev; we don't have a way to scope brand in dev) + * - store_employee: `[]` + * - brand_admin: `[]` */ export async function getAdminUser(): Promise { const cookieStore = await cookies(); @@ -45,65 +49,7 @@ export async function getAdminUser(): Promise { if (admin) return admin; } - // ── Main auth: rc_auth_uid (new) or rc_uid (legacy) cookie set by /api/login ─ - const uid = cookieStore.get("rc_auth_uid")?.value ?? cookieStore.get("rc_uid")?.value; - if (!uid) return null; - - if (!process.env.SUPABASE_SERVICE_ROLE_KEY || !process.env.NEXT_PUBLIC_SUPABASE_URL) { - return null; - } - - // Lookup admin_users by Supabase auth user id - const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; - const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY!; - let adminUsers: unknown[] = []; - try { - const res = await fetch( - `${supabaseUrl}/rest/v1/admin_users?user_id=eq.${uid}&limit=1`, - { headers: { apikey: serviceKey, "Content-Type": "application/json" } } - ); - if (res.ok) { - const data = await res.json().catch(() => []); - adminUsers = Array.isArray(data) ? data : []; - } - } catch (e) { - // fetch failed silently - } - - // First login — auto-create platform_admin via SECURITY DEFINER RPC - if (adminUsers.length === 0) { - // Check if uid is a valid UUID before trying to insert - const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; - if (!UUID_REGEX.test(uid)) return null; - - try { - const res = await fetch( - `${supabaseUrl}/rest/v1/rpc/upsert_admin_user`, - { - method: "POST", - headers: { apikey: serviceKey, "Content-Type": "application/json", Prefer: "return=representation" }, - body: JSON.stringify({ p_user_id: uid }), - } - ); - if (res.ok) { - const inserted = await res.json().catch(() => null); - if (inserted && inserted.length > 0) { - return buildAdminUser(inserted[0] as Record, []); - } - } - } catch (e) { - // RPC failed silently - } - return null; - } - - const admin = adminUsers[0] as Record; - if (!admin.active) return null; - - // Load brand_ids from the admin_user_brands junction - const brandIds = await fetchAdminUserBrandIds(supabaseUrl, serviceKey, admin.id as string); - - return buildAdminUser(admin, brandIds); + return null; } /** @@ -149,31 +95,6 @@ async function fetchAdminUserBrandIdsFromPool(adminRowId: string): Promise { - try { - const res = await fetch( - `${supabaseUrl}/rest/v1/admin_user_brands?admin_user_id=eq.${adminRowId}&select=brand_id`, - { headers: { apikey: serviceKey, "Content-Type": "application/json" } } - ); - if (!res.ok) return []; - const data = await res.json().catch(() => []); - if (!Array.isArray(data)) return []; - return data - .map((row: Record) => row.brand_id as string) - .filter((id): id is string => typeof id === "string"); - } catch { - return []; - } -} - function buildDevAdmin(role: string): AdminUser { // For dev sessions we don't have an admin_user_brands junction row to load. // - platform_admin: `brand_ids = []` (listBrandsForAdmin resolves against brands). diff --git a/src/lib/auth.ts b/src/lib/auth.ts index d3f02f8..0f7bbcb 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -46,6 +46,22 @@ function buildDevCredentialsProvider() { * Note: when using a database adapter the session strategy is fixed to * "database" — Auth.js will persist sessions in the `sessions` table. */ +/** + * Parse the ADMIN_ALLOWED_EMAILS env var into a lowercased set. + * Returns `null` if the env var is unset/empty — the caller should treat + * `null` as "no allowlist, allow everyone" (backward compatible with + * demo/dev where you want any Google account to get in). + */ +function parseAdminAllowlist(): Set | null { + const raw = process.env.ADMIN_ALLOWED_EMAILS; + if (!raw) return null; + const emails = raw + .split(",") + .map((e) => e.trim().toLowerCase()) + .filter(Boolean); + return emails.length > 0 ? new Set(emails) : null; +} + export const { handlers, auth, signIn, signOut } = NextAuth({ ...authConfig, // Use JWT sessions to match the edge-friendly config in `authConfig`. @@ -62,6 +78,32 @@ export const { handlers, auth, signIn, signOut } = NextAuth({ ...authConfig.providers, ...(isDevLoginEnabled() ? [buildDevCredentialsProvider()] : []), ], + callbacks: { + /** + * Sign-in gate. If ADMIN_ALLOWED_EMAILS is set, the user's email + * must be in the list. Returns `false` to deny the sign-in. + * + * The dev credentials provider is exempt — dev users have synthetic + * emails like "admin@dev.local" and shouldn't be blocked by a + * production allowlist. + */ + async signIn({ user, account }) { + // Bypass allowlist for the dev credentials provider. + if (account?.provider === "dev-login") return true; + + const allowlist = parseAdminAllowlist(); + if (!allowlist) return true; // No allowlist configured — open mode. + + const email = user.email?.toLowerCase(); + if (!email || !allowlist.has(email)) { + console.warn( + `[auth] signIn denied: ${email ?? ""} not in ADMIN_ALLOWED_EMAILS` + ); + return false; + } + return true; + }, + }, events: { /** * First-time sign-in: auto-create a `platform_admin` row in