Merge remote-tracking branch 'crispygoat/main'
Deploy to route.crispygoat.com / deploy (push) Failing after 1m59s
Deploy to route.crispygoat.com / deploy (push) Failing after 1m59s
# Conflicts: # src/actions/admin/password.ts # src/actions/brand-settings.ts # src/actions/stops.ts # src/app/change-password/page.tsx # src/app/login/LoginClient.tsx # src/app/logout/page.tsx # src/auth.config.ts # src/lib/admin-permissions.ts # src/lib/auth.ts # src/lib/db.ts
This commit is contained in:
@@ -1,7 +1,6 @@
|
||||
"use server";
|
||||
|
||||
import { signIn, signOut } from "@/lib/auth";
|
||||
import { AuthError } from "next-auth";
|
||||
|
||||
/**
|
||||
* Server actions that wrap the Auth.js v5 `signIn` / `signOut` API for
|
||||
@@ -19,38 +18,15 @@ import { AuthError } from "next-auth";
|
||||
* <button type="submit">Sign in with Google</button>
|
||||
* </form>
|
||||
*
|
||||
* Usage for the dev credentials provider (dev only):
|
||||
* <form action={signInWithDev}>
|
||||
* <input name="username" />
|
||||
* <input name="password" type="password" />
|
||||
* <button type="submit">Dev login</button>
|
||||
* </form>
|
||||
* Note: dev/demo authentication is no longer a button on the login page.
|
||||
* `src/middleware.ts` auto-issues the `dev_session` cookie for /admin/*
|
||||
* when ALLOW_DEV_LOGIN is enabled. See CLAUDE.md.
|
||||
*/
|
||||
|
||||
export async function signInWithGoogle(): Promise<void> {
|
||||
await signIn("google", { redirectTo: "/admin" });
|
||||
}
|
||||
|
||||
export async function signInWithDev(formData: FormData): Promise<void> {
|
||||
const username = String(formData.get("username") ?? "admin");
|
||||
const password = String(formData.get("password") ?? "dev");
|
||||
try {
|
||||
await signIn("dev-login", {
|
||||
username,
|
||||
password,
|
||||
redirectTo: "/admin",
|
||||
});
|
||||
} catch (e) {
|
||||
// signIn() throws a `NEXT_REDIRECT` to navigate — let that through
|
||||
// so the redirect actually happens. Re-throw any other error so the
|
||||
// caller can render a meaningful message.
|
||||
if (e instanceof AuthError) {
|
||||
throw new Error(`Dev sign-in failed: ${e.type}`);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
export async function signOutAction(): Promise<void> {
|
||||
await signOut({ redirectTo: "/login" });
|
||||
}
|
||||
|
||||
@@ -8,6 +8,11 @@ import "@/styles/admin-design-system.css";
|
||||
import { ToastProvider } from "@/components/admin/Toast";
|
||||
import { ToastContainer } from "@/components/admin/ToastContainer";
|
||||
|
||||
// Admin layout calls getAdminUser() which reads cookies(). Without this,
|
||||
// Next.js tries to prerender the entire /admin/* tree statically and the
|
||||
// first page that hits cookies() aborts the build with DYNAMIC_SERVER_USAGE.
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
// Toast provider wrapper component
|
||||
function ToastProviderWrapper({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
@@ -59,13 +64,25 @@ export default async function AdminLayout({ children }: { children: React.ReactN
|
||||
redirect("/change-password");
|
||||
}
|
||||
|
||||
// Resolve the active brand (URL > cookie > legacy > first of brand_ids)
|
||||
const activeBrandId = await getActiveBrandId(adminUser);
|
||||
// Resolve the active brand (URL > cookie > legacy > first of brand_ids).
|
||||
// Wrapped in try/catch so a transient brand-resolution failure can't
|
||||
// crash the whole admin shell — we fall back to null (no active brand).
|
||||
let activeBrandId: string | null = null;
|
||||
try {
|
||||
activeBrandId = await getActiveBrandId(adminUser);
|
||||
} catch (err) {
|
||||
console.error("[admin/layout] getActiveBrandId failed:", err);
|
||||
}
|
||||
|
||||
// Fetch accessible brands for the sidebar BrandSelector. We do this
|
||||
// unconditionally — `listBrandsForAdmin` is cheap and the sidebar
|
||||
// decides whether to show the dropdown.
|
||||
const brands = await listBrandsForAdmin();
|
||||
// Fetch accessible brands for the sidebar BrandSelector. Wrapped in
|
||||
// try/catch so the sidebar renders empty rather than crashing the page
|
||||
// if the brands query fails.
|
||||
let brands: Awaited<ReturnType<typeof listBrandsForAdmin>> = [];
|
||||
try {
|
||||
brands = await listBrandsForAdmin();
|
||||
} catch (err) {
|
||||
console.error("[admin/layout] listBrandsForAdmin failed:", err);
|
||||
}
|
||||
|
||||
return (
|
||||
<ToastProviderWrapper>
|
||||
|
||||
+13
-8
@@ -27,16 +27,21 @@ export default async function AdminPage() {
|
||||
// Resolve active brand via the canonical resolver (URL > cookie > legacy brand_id
|
||||
// > first of brand_ids). For platform_admin in dev mode this is null, so we
|
||||
// fall back to the first brand in the brands table to keep the dashboard's
|
||||
// "Active Products" stat in sync with the billing page.
|
||||
// "Active Products" stat in sync with the billing page. Wrapped in try/catch
|
||||
// so a transient DB/network failure can't crash the whole admin page.
|
||||
let dashboardBrandId: string | null = adminUser ? await getActiveBrandId(adminUser) : null;
|
||||
if (!dashboardBrandId && adminUser?.role === "platform_admin") {
|
||||
const { data: firstBrand } = await supabase
|
||||
.from("brands")
|
||||
.select("id")
|
||||
.limit(1)
|
||||
.single();
|
||||
if (firstBrand?.id) {
|
||||
dashboardBrandId = firstBrand.id;
|
||||
try {
|
||||
const { data: firstBrand } = await supabase
|
||||
.from("brands")
|
||||
.select("id")
|
||||
.limit(1)
|
||||
.single();
|
||||
if (firstBrand?.id) {
|
||||
dashboardBrandId = firstBrand.id;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[admin/page] supabase brands lookup failed:", err);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
+72
-15
@@ -1,26 +1,83 @@
|
||||
import NextAuth from "next-auth";
|
||||
import { authConfig } from "@/auth.config";
|
||||
import { NextResponse, type NextRequest } from "next/server";
|
||||
|
||||
/**
|
||||
* Root-level proxy (formerly `middleware.ts`, renamed in Next.js 16).
|
||||
* This is the single source of truth for route protection. The legacy
|
||||
* `src/middleware.ts` has been deleted (Next.js only runs one).
|
||||
*
|
||||
* Why an `auth` wrapper instead of a hand-rolled `NextResponse.next()`?
|
||||
* 1. Auth.js v5 ships an `authorized` callback in `authConfig` that
|
||||
* knows which routes need a session. We reuse it here at the edge.
|
||||
* 2. It auto-populates `request.auth` with the session (JWT-decoded)
|
||||
* for any server component/page that reads `auth()` later.
|
||||
* Routing policy:
|
||||
* 1. `/admin/*` with no auth cookie + `ALLOW_DEV_LOGIN !== "false"`
|
||||
* → set `dev_session=platform_admin` and let the request through.
|
||||
* This makes `/admin` "just work" in dev/demo without any login
|
||||
* UI gymnastics.
|
||||
* 2. `/login` with an auth cookie (any flavour)
|
||||
* → redirect to `/admin` so an authenticated user never sees the
|
||||
* login form.
|
||||
* 3. `/admin/*` (or `/protected-example`) with no auth cookie
|
||||
* → redirect to `/login`.
|
||||
* 4. Everything else → continue.
|
||||
*
|
||||
* Public routes, admin gating, and the `auth` cookie are all configured
|
||||
* in `src/auth.config.ts`.
|
||||
* Auth-cookie flavours recognised:
|
||||
* - `dev_session` (dev auto-login, see above)
|
||||
* - `rc_auth_uid` / `rc_uid` (legacy /api/login flow)
|
||||
* - `authjs.session-token` / `__Secure-authjs.session-token` (Auth.js v5)
|
||||
*
|
||||
* The proxy only checks cookie *presence*. The real auth check (JWT
|
||||
* signature decryption, admin role lookup) happens in
|
||||
* `getAdminUser()` server-side. The proxy is just routing.
|
||||
*/
|
||||
const { auth } = NextAuth(authConfig);
|
||||
|
||||
export default auth;
|
||||
function isAuthenticated(request: NextRequest): boolean {
|
||||
const dev = request.cookies.get("dev_session")?.value;
|
||||
if (
|
||||
dev === "platform_admin" ||
|
||||
dev === "brand_admin" ||
|
||||
dev === "store_employee"
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (request.cookies.get("rc_auth_uid")?.value) return true;
|
||||
if (request.cookies.get("rc_uid")?.value) return true;
|
||||
if (request.cookies.get("authjs.session-token")?.value) return true;
|
||||
if (request.cookies.get("__Secure-authjs.session-token")?.value) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
export default function proxy(request: NextRequest) {
|
||||
const { nextUrl } = request;
|
||||
const isOnAdmin = nextUrl.pathname.startsWith("/admin");
|
||||
const isOnProtectedExample = nextUrl.pathname.startsWith(
|
||||
"/protected-example"
|
||||
);
|
||||
const isOnLogin = nextUrl.pathname === "/login";
|
||||
const authenticated = isAuthenticated(request);
|
||||
|
||||
// ── 1. Dev auto-login for /admin/* ───────────────────────────────
|
||||
if (isOnAdmin && !authenticated) {
|
||||
const allowDev = process.env.ALLOW_DEV_LOGIN !== "false";
|
||||
if (allowDev) {
|
||||
const response = NextResponse.next();
|
||||
response.cookies.set("dev_session", "platform_admin", {
|
||||
path: "/",
|
||||
maxAge: 60 * 60 * 24, // 1 day
|
||||
sameSite: "lax",
|
||||
});
|
||||
return response;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 2. Bounce authenticated users away from /login ───────────────
|
||||
if (isOnLogin && isAuthenticated(request)) {
|
||||
return NextResponse.redirect(new URL("/admin", nextUrl));
|
||||
}
|
||||
|
||||
// ── 3. Gate protected routes ─────────────────────────────────────
|
||||
if ((isOnAdmin || isOnProtectedExample) && !authenticated) {
|
||||
return NextResponse.redirect(new URL("/login", nextUrl));
|
||||
}
|
||||
|
||||
// ── 4. Everything else: continue ─────────────────────────────────
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
export const config = {
|
||||
// Run on /admin and the protected example, plus /login so the
|
||||
// `authorized` callback can bounce already-signed-in users away from it.
|
||||
matcher: ["/admin/:path*", "/admin", "/login", "/protected-example"],
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user