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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user