chore(auth): remove legacy rc_auth_uid/rc_uid/upsert_admin_user path; scope sign-in to ADMIN_ALLOWED_EMAILS; silence lockfile warning
Deploy to route.crispygoat.com / deploy (push) Successful in 3m14s
Deploy to route.crispygoat.com / deploy (push) Successful in 3m14s
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.
This commit is contained in:
@@ -40,6 +40,13 @@ GOOGLE_CLIENT_SECRET=
|
|||||||
# development. Default: enabled in dev only.
|
# development. Default: enabled in dev only.
|
||||||
ALLOW_DEV_LOGIN=true
|
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) ────────────────────────────────────────
|
# ── Supabase (legacy, being removed) ────────────────────────────────────────
|
||||||
# Still used by the existing admin pages, server actions, and the
|
# Still used by the existing admin pages, server actions, and the
|
||||||
# `getAdminUser` flow. Once the auth migration is complete and the
|
# `getAdminUser` flow. Once the auth migration is complete and the
|
||||||
|
|||||||
@@ -169,6 +169,7 @@ jobs:
|
|||||||
GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID || secrets.AUTH_GOOGLE_ID }}
|
GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID || secrets.AUTH_GOOGLE_ID }}
|
||||||
GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET || secrets.AUTH_GOOGLE_SECRET }}
|
GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET || secrets.AUTH_GOOGLE_SECRET }}
|
||||||
ALLOW_DEV_LOGIN: ${{ secrets.ALLOW_DEV_LOGIN }}
|
ALLOW_DEV_LOGIN: ${{ secrets.ALLOW_DEV_LOGIN }}
|
||||||
|
ADMIN_ALLOWED_EMAILS: ${{ secrets.ADMIN_ALLOWED_EMAILS }}
|
||||||
|
|
||||||
# Supabase (legacy, still used by admin pages/server actions until
|
# Supabase (legacy, still used by admin pages/server actions until
|
||||||
# the Auth.js migration is finished)
|
# the Auth.js migration is finished)
|
||||||
@@ -221,6 +222,7 @@ jobs:
|
|||||||
GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID || secrets.AUTH_GOOGLE_ID }}
|
GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID || secrets.AUTH_GOOGLE_ID }}
|
||||||
GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET || secrets.AUTH_GOOGLE_SECRET }}
|
GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET || secrets.AUTH_GOOGLE_SECRET }}
|
||||||
ALLOW_DEV_LOGIN: ${{ secrets.ALLOW_DEV_LOGIN }}
|
ALLOW_DEV_LOGIN: ${{ secrets.ALLOW_DEV_LOGIN }}
|
||||||
|
ADMIN_ALLOWED_EMAILS: ${{ secrets.ADMIN_ALLOWED_EMAILS }}
|
||||||
|
|
||||||
# Storage
|
# Storage
|
||||||
STORAGE_ENDPOINT: ${{ secrets.STORAGE_ENDPOINT }}
|
STORAGE_ENDPOINT: ${{ secrets.STORAGE_ENDPOINT }}
|
||||||
@@ -277,6 +279,7 @@ jobs:
|
|||||||
printf "GOOGLE_CLIENT_ID=%s\n" "$GOOGLE_CLIENT_ID"
|
printf "GOOGLE_CLIENT_ID=%s\n" "$GOOGLE_CLIENT_ID"
|
||||||
printf "GOOGLE_CLIENT_SECRET=%s\n" "$GOOGLE_CLIENT_SECRET"
|
printf "GOOGLE_CLIENT_SECRET=%s\n" "$GOOGLE_CLIENT_SECRET"
|
||||||
printf "ALLOW_DEV_LOGIN=%s\n" "$ALLOW_DEV_LOGIN"
|
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_ENDPOINT=%s\n" "$STORAGE_ENDPOINT"
|
||||||
printf "STORAGE_REGION=%s\n" "$STORAGE_REGION"
|
printf "STORAGE_REGION=%s\n" "$STORAGE_REGION"
|
||||||
printf "STORAGE_ACCESS_KEY=%s\n" "$STORAGE_ACCESS_KEY"
|
printf "STORAGE_ACCESS_KEY=%s\n" "$STORAGE_ACCESS_KEY"
|
||||||
|
|||||||
@@ -1,6 +1,17 @@
|
|||||||
import type { NextConfig } from "next";
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
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
|
// Enable strict mode
|
||||||
reactStrictMode: true,
|
reactStrictMode: true,
|
||||||
|
|
||||||
|
|||||||
@@ -1,18 +1,24 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { cookies } from "next/headers";
|
import { auth } from "@/lib/auth";
|
||||||
import { createClient as createServiceClient } from "@supabase/supabase-js";
|
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(
|
export async function updatePasswordAction(
|
||||||
newPassword: string
|
newPassword: string
|
||||||
): Promise<{ error?: string }> {
|
): Promise<{ error?: string }> {
|
||||||
const cookieStore = await cookies();
|
const session = await auth();
|
||||||
const uid =
|
const userId = session?.user?.id;
|
||||||
cookieStore.get("rc_auth_uid")?.value ??
|
|
||||||
cookieStore.get("rc_uid")?.value;
|
|
||||||
|
|
||||||
if (!uid) {
|
if (!userId) {
|
||||||
return { error: "Not authenticated. Please log in again." };
|
return { error: "Not authenticated. Please sign in again." };
|
||||||
}
|
}
|
||||||
|
|
||||||
const service = createServiceClient(
|
const service = createServiceClient(
|
||||||
@@ -21,10 +27,10 @@ export async function updatePasswordAction(
|
|||||||
);
|
);
|
||||||
|
|
||||||
const { error } = await service.rpc("update_user_password", {
|
const { error } = await service.rpc("update_user_password", {
|
||||||
p_user_id: uid,
|
p_user_id: userId,
|
||||||
p_password: newPassword,
|
p_password: newPassword,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (error) return { error: error.message };
|
if (error) return { error: error.message };
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<LoginWithPasswordResult> {
|
|
||||||
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 };
|
|
||||||
}
|
|
||||||
@@ -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 (
|
|
||||||
<div className="min-h-screen bg-zinc-950 p-8 text-white">
|
|
||||||
<h1 className="text-2xl font-bold text-emerald-400 mb-6">Auth Debug</h1>
|
|
||||||
|
|
||||||
<div className="mb-6">
|
|
||||||
<h2 className="text-xs font-semibold text-stone-400 uppercase mb-2">Cookies ({allCookies.length})</h2>
|
|
||||||
<pre className="bg-black/50 p-4 rounded-xl text-xs overflow-auto max-h-64">
|
|
||||||
{allCookies.map(c => `${c.name}=${c.value.slice(0, 80)}${c.value.length > 80 ? "..." : ""}`).join("\n") || "(none)"}
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mb-6">
|
|
||||||
<h2 className="text-xs font-semibold text-stone-400 uppercase mb-2">Key Cookies</h2>
|
|
||||||
<p>rc_auth_uid: <span className={rcAuthUid ? "text-emerald-400" : "text-red-400"}>{rcAuthUid ? `${rcAuthUid.slice(0, 20)}...` : "NOT SET"}</span></p>
|
|
||||||
<p>rc_uid: <span className={rcUid ? "text-emerald-400" : "text-red-400"}>{rcUid ? `${rcUid.slice(0, 20)}...` : "NOT SET"}</span></p>
|
|
||||||
<p>rc_access_token: <span className={rcAccessToken ? "text-yellow-400" : "text-red-400"}>{rcAccessToken ? "PRESENT" : "NOT SET"}</span></p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mb-6">
|
|
||||||
<h2 className="text-xs font-semibold text-stone-400 uppercase mb-2">Admin Users Lookup</h2>
|
|
||||||
<p>Status: <span className="text-lg font-mono">{adminUsersStatus}</span></p>
|
|
||||||
<p>Result: <pre className="bg-black/50 p-4 rounded-xl text-xs overflow-auto mt-2">{adminUsersResult || "(none)"}</pre></p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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 (
|
|
||||||
<div className="min-h-screen bg-zinc-950 p-8 text-white">
|
|
||||||
<h1 className="text-2xl font-bold mb-6 text-emerald-400">Auth Debug</h1>
|
|
||||||
|
|
||||||
<div className="mb-6">
|
|
||||||
<h2 className="text-xs font-semibold text-stone-400 uppercase mb-2">All Cookies ({allCookies.length})</h2>
|
|
||||||
<pre className="bg-black/50 p-4 rounded-xl text-xs overflow-auto max-h-64">
|
|
||||||
{allCookies.map(c => `${c.name}=${c.value.slice(0, 80)}${c.value.length > 80 ? "..." : ""}`).join("\n") || "(none)"}
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mb-6">
|
|
||||||
<h2 className="text-xs font-semibold text-stone-400 uppercase mb-2">rc_auth_uid</h2>
|
|
||||||
<p className="text-lg font-mono">
|
|
||||||
{allCookies.some(c => c.name === "rc_auth_uid")
|
|
||||||
? <span className="text-emerald-400">SET — {allCookies.find(c => c.name === "rc_auth_uid")?.value}</span>
|
|
||||||
: <span className="text-red-400">NOT SET</span>
|
|
||||||
}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mb-6">
|
|
||||||
<h2 className="text-xs font-semibold text-stone-400 uppercase mb-2">rc_access_token</h2>
|
|
||||||
<p className="text-lg font-mono">
|
|
||||||
{allCookies.some(c => c.name === "rc_access_token")
|
|
||||||
? <span className="text-yellow-400">SET (not needed)</span>
|
|
||||||
: <span className="text-zinc-500">NOT SET (OK)</span>
|
|
||||||
}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mb-6">
|
|
||||||
<h2 className="text-xs font-semibold text-stone-400 uppercase mb-2">getAdminUser() result</h2>
|
|
||||||
{error ? (
|
|
||||||
<div>
|
|
||||||
<p className="text-red-400 font-bold">ERROR</p>
|
|
||||||
<pre className="bg-red-950/50 p-4 rounded-xl text-sm mt-2">{error}</pre>
|
|
||||||
</div>
|
|
||||||
) : adminUser ? (
|
|
||||||
<div>
|
|
||||||
<p className="text-emerald-400 font-bold">AUTHENTICATED</p>
|
|
||||||
<pre className="bg-black/50 p-4 rounded-xl text-sm mt-2 overflow-auto">
|
|
||||||
{JSON.stringify({
|
|
||||||
id: adminUser.id,
|
|
||||||
user_id: adminUser.user_id,
|
|
||||||
role: adminUser.role,
|
|
||||||
brand_id: adminUser.brand_id,
|
|
||||||
active: adminUser.active,
|
|
||||||
}, null, 2)}
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<p className="text-red-400">NOT AUTHENTICATED — null returned</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-8 pt-4 border-t border-zinc-800">
|
|
||||||
<h2 className="text-xs font-semibold text-stone-400 uppercase mb-2">Quick Actions</h2>
|
|
||||||
<div className="flex gap-4">
|
|
||||||
<a href="/admin" className="px-4 py-2 bg-emerald-600 text-white rounded-xl text-sm font-bold hover:bg-emerald-500">
|
|
||||||
Go to Admin
|
|
||||||
</a>
|
|
||||||
<form action="/api/logout" method="POST">
|
|
||||||
<button type="submit" className="px-4 py-2 bg-zinc-800 text-white rounded-xl text-sm font-bold hover:bg-zinc-700">
|
|
||||||
Logout
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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 });
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -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),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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 });
|
|
||||||
}
|
|
||||||
@@ -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 });
|
|
||||||
}
|
|
||||||
@@ -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 (
|
|
||||||
<div className="min-h-screen bg-zinc-950 flex items-center justify-center">
|
|
||||||
<div className="text-zinc-400">Verifying reset link...</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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<string | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||||
|
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 (
|
||||||
|
<main className="min-h-screen bg-zinc-950 px-6 py-12 flex items-start justify-center">
|
||||||
|
<div className="w-full max-w-md">
|
||||||
|
<div className="rounded-2xl bg-zinc-900 border border-zinc-800 p-8">
|
||||||
|
<div className="mb-6 flex h-12 w-12 items-center justify-center rounded-full bg-amber-900/30 border border-amber-700/40">
|
||||||
|
<svg
|
||||||
|
className="h-6 w-6 text-amber-400"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h1 className="text-2xl font-bold text-zinc-100">Change Password</h1>
|
||||||
|
<p className="mt-1 text-sm text-zinc-500">
|
||||||
|
You must set a new password before continuing.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="mt-6 space-y-4">
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-xl bg-red-900/20 border border-red-800/50 p-4 text-sm text-red-400">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-zinc-400 mb-1">
|
||||||
|
New Password
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => 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
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-zinc-400 mb-1">
|
||||||
|
Confirm Password
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={confirm}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full rounded-xl bg-violet-600 hover:bg-violet-500 px-6 py-4 text-base font-bold text-white disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
{loading ? "Updating..." : "Update Password"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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";
|
export const dynamic = "force-dynamic";
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { updatePasswordAction } from "@/actions/admin/password";
|
|
||||||
import { logUserActivity } from "@/actions/admin/audit";
|
|
||||||
|
|
||||||
export default function ChangePasswordPage() {
|
/**
|
||||||
const router = useRouter();
|
* Forced password-change page.
|
||||||
const [password, setPassword] = useState("");
|
*
|
||||||
const [confirm, setConfirm] = useState("");
|
* Linked from `src/app/admin/layout.tsx:64` when the admin user's row
|
||||||
const [error, setError] = useState<string | null>(null);
|
* has `must_change_password = true`. Verifies the Auth.js session
|
||||||
const [loading, setLoading] = useState(false);
|
* server-side, then renders the form with the user id passed as a prop.
|
||||||
const [checkingSession, setCheckingSession] = useState(true);
|
*
|
||||||
const [userId, setUserId] = useState<string | null>(null);
|
* 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(() => {
|
if (!userId) {
|
||||||
fetch("/api/auth/uid")
|
redirect("/login");
|
||||||
.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<HTMLFormElement>) {
|
|
||||||
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 (checkingSession) {
|
return <ChangePasswordForm userId={userId} />;
|
||||||
return (
|
}
|
||||||
<main className="min-h-screen bg-zinc-950 px-6 py-12 flex items-start justify-center">
|
|
||||||
<div className="w-full max-w-md">
|
|
||||||
<div className="rounded-2xl bg-zinc-900 border border-zinc-800 p-8 text-center">
|
|
||||||
<p className="text-zinc-500 text-sm">Checking session...</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<main className="min-h-screen bg-zinc-950 px-6 py-12 flex items-start justify-center">
|
|
||||||
<div className="w-full max-w-md">
|
|
||||||
<div className="rounded-2xl bg-zinc-900 border border-zinc-800 p-8">
|
|
||||||
<div className="mb-6 flex h-12 w-12 items-center justify-center rounded-full bg-amber-900/30 border border-amber-700/40">
|
|
||||||
<svg className="h-6 w-6 text-amber-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<h1 className="text-2xl font-bold text-zinc-100">Change Password</h1>
|
|
||||||
<p className="mt-1 text-sm text-zinc-500">
|
|
||||||
You must set a new password before continuing.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="mt-6 space-y-4">
|
|
||||||
{error && (
|
|
||||||
<div className="rounded-xl bg-red-900/20 border border-red-800/50 p-4 text-sm text-red-400">
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-zinc-400 mb-1">
|
|
||||||
New Password
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
value={password}
|
|
||||||
onChange={(e) => 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
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-zinc-400 mb-1">
|
|
||||||
Confirm Password
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
value={confirm}
|
|
||||||
onChange={(e) => 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"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={loading}
|
|
||||||
className="w-full rounded-xl bg-violet-600 hover:bg-violet-500 px-6 py-4 text-base font-bold text-white disabled:opacity-50 transition-colors"
|
|
||||||
>
|
|
||||||
{loading ? "Updating..." : "Update Password"}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<div className="mt-4 border-t border-zinc-800 pt-4">
|
|
||||||
<Link
|
|
||||||
href="/logout"
|
|
||||||
className="block text-center text-sm text-zinc-500 hover:text-zinc-300 transition-colors"
|
|
||||||
>
|
|
||||||
Sign out instead
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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<string | null>(null);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
|
|
||||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
|
||||||
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 (
|
|
||||||
<main className="min-h-screen bg-slate-100 px-6 py-12">
|
|
||||||
<div className="mx-auto max-w-md">
|
|
||||||
<div className="rounded-2xl bg-white p-8 shadow-sm ring-1 ring-slate-200">
|
|
||||||
<h1 className="text-2xl font-bold text-slate-900">Admin Login</h1>
|
|
||||||
<p className="mt-1 text-sm text-slate-500">Sign in to your account.</p>
|
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="mt-6 space-y-4">
|
|
||||||
{error && (
|
|
||||||
<div role="alert" className="rounded-xl bg-red-50 p-4 text-sm text-red-700 border border-red-100">
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label htmlFor="email" className="block text-sm font-medium text-slate-700">Email</label>
|
|
||||||
<input
|
|
||||||
id="email"
|
|
||||||
type="email"
|
|
||||||
value={email}
|
|
||||||
onChange={(e) => 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"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label htmlFor="password" className="block text-sm font-medium text-slate-700">Password</label>
|
|
||||||
<input
|
|
||||||
id="password"
|
|
||||||
type="password"
|
|
||||||
value={password}
|
|
||||||
onChange={(e) => 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="••••••••"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={loading}
|
|
||||||
className="w-full rounded-xl bg-slate-900 px-6 py-4 text-base font-bold text-white disabled:opacity-50 flex items-center justify-center gap-3"
|
|
||||||
>
|
|
||||||
{loading ? (
|
|
||||||
<>
|
|
||||||
<svg className="h-5 w-5 animate-spin" viewBox="0 0 24 24" fill="none">
|
|
||||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
|
||||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
|
||||||
</svg>
|
|
||||||
Signing in...
|
|
||||||
</>
|
|
||||||
) : "Sign In"}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Link href="/" className="mt-4 block text-sm text-slate-500 hover:text-slate-700">
|
|
||||||
← Back to storefront
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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 (
|
|
||||||
<main className="min-h-screen bg-slate-100 px-6 py-12">
|
|
||||||
<div className="mx-auto max-w-md">
|
|
||||||
<div className="rounded-2xl bg-white p-8 shadow-sm ring-1 ring-slate-200 text-center">
|
|
||||||
<p className="text-slate-500">Signing out...</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -12,13 +12,17 @@ export type { AdminUser } from "./admin-permissions-types";
|
|||||||
* 2. `dev_session` cookie → dev admin (platform_admin/brand_admin/store_employee).
|
* 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
|
* 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).
|
* 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.
|
* `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:
|
* For dev sessions without a real DB, `brand_ids` is populated by:
|
||||||
* - platform_admin: `[]` (listBrandsForAdmin resolves against the brands table)
|
* - platform_admin: `[]` (listBrandsForAdmin resolves against the brands table)
|
||||||
* - store_employee: `[<first real brand>]` if a brand exists, else `[]`
|
* - store_employee: `[]`
|
||||||
* - brand_admin: `[]` (legacy dev; we don't have a way to scope brand in dev)
|
* - brand_admin: `[]`
|
||||||
*/
|
*/
|
||||||
export async function getAdminUser(): Promise<AdminUser | null> {
|
export async function getAdminUser(): Promise<AdminUser | null> {
|
||||||
const cookieStore = await cookies();
|
const cookieStore = await cookies();
|
||||||
@@ -45,65 +49,7 @@ export async function getAdminUser(): Promise<AdminUser | null> {
|
|||||||
if (admin) return admin;
|
if (admin) return admin;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Main auth: rc_auth_uid (new) or rc_uid (legacy) cookie set by /api/login ─
|
return null;
|
||||||
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<string, unknown>, []);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
// RPC failed silently
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const admin = adminUsers[0] as Record<string, unknown>;
|
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -149,31 +95,6 @@ async function fetchAdminUserBrandIdsFromPool(adminRowId: string): Promise<strin
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Load `brand_ids` from the admin_user_brands junction for the given admin row.
|
|
||||||
* Returns an empty array on any failure (e.g. before migration 207 is applied).
|
|
||||||
*/
|
|
||||||
async function fetchAdminUserBrandIds(
|
|
||||||
supabaseUrl: string,
|
|
||||||
serviceKey: string,
|
|
||||||
adminRowId: string
|
|
||||||
): Promise<string[]> {
|
|
||||||
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<string, unknown>) => row.brand_id as string)
|
|
||||||
.filter((id): id is string => typeof id === "string");
|
|
||||||
} catch {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildDevAdmin(role: string): AdminUser {
|
function buildDevAdmin(role: string): AdminUser {
|
||||||
// For dev sessions we don't have an admin_user_brands junction row to load.
|
// For dev sessions we don't have an admin_user_brands junction row to load.
|
||||||
// - platform_admin: `brand_ids = []` (listBrandsForAdmin resolves against brands).
|
// - platform_admin: `brand_ids = []` (listBrandsForAdmin resolves against brands).
|
||||||
|
|||||||
@@ -46,6 +46,22 @@ function buildDevCredentialsProvider() {
|
|||||||
* Note: when using a database adapter the session strategy is fixed to
|
* Note: when using a database adapter the session strategy is fixed to
|
||||||
* "database" — Auth.js will persist sessions in the `sessions` table.
|
* "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<string> | 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({
|
export const { handlers, auth, signIn, signOut } = NextAuth({
|
||||||
...authConfig,
|
...authConfig,
|
||||||
// Use JWT sessions to match the edge-friendly config in `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,
|
...authConfig.providers,
|
||||||
...(isDevLoginEnabled() ? [buildDevCredentialsProvider()] : []),
|
...(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 ?? "<no email>"} not in ADMIN_ALLOWED_EMAILS`
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
},
|
||||||
events: {
|
events: {
|
||||||
/**
|
/**
|
||||||
* First-time sign-in: auto-create a `platform_admin` row in
|
* First-time sign-in: auto-create a `platform_admin` row in
|
||||||
|
|||||||
Reference in New Issue
Block a user