Enforce admin CSRF, harden catalog translate, use CMS hotel name for PayPal.
Local Build and Deploy / deploy (push) Successful in 1m38s

Mutating withAdmin routes now require a double-submit CSRF token; translate is capped at 500 items with audit logging; PayPal descriptions prefer siteSettings hotel_name.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
SimoandCursor committed 2026-07-18 19:38:42 +02:00
1 parent 6b884ad25a
commit 2de3696993
18 files changed
+218 -62

No files matched your search

@@ -19,6 +19,7 @@ import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
import { adminFetch } from "@/lib/admin-fetch";
// ── Types ─────────────────────────────────────────────────────────────────────
@@ -62,7 +63,7 @@ async function runSseImport(
onDone: (classname: string) => void,
onComplete: (succeeded: number, failed: number) => void,
): Promise<void> {
const res = await fetch(url, {
const res = await adminFetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
@@ -202,7 +203,7 @@ function SourcesManager({
async function handleSave(src: Partial<CloneSource>) {
setSaving(true);
try {
const res = await fetch("/api/admin/import/clone", {
const res = await adminFetch("/api/admin/import/clone", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(src),
@@ -224,7 +225,7 @@ function SourcesManager({
async function handleDelete(src: CloneSource) {
setDeletingId(src.id);
try {
const res = await fetch(
const res = await adminFetch(
`/api/admin/import/clone?id=${encodeURIComponent(src.id)}`,
{
method: "DELETE",
@@ -417,7 +418,7 @@ function FurniGrid({ source }: FurniGridProps) {
});
if (search) params.set("search", search);
if (filterVal !== "all") params.set("filter", filterVal);
const res = await fetch(`/api/admin/import/clone?${params}`);
const res = await adminFetch(`/api/admin/import/clone?${params}`);
const data = await res.json();
if (!res.ok) {
setError(data.error || "Failed to load");
@@ -535,7 +536,7 @@ function FurniGrid({ source }: FurniGridProps) {
async function cloneAll() {
let names: string[] = [];
try {
const res = await fetch(
const res = await adminFetch(
`/api/admin/import/clone?source=${encodeURIComponent(source.id)}&action=clonable`,
);
const data = await res.json();
@@ -913,7 +914,7 @@ export function ImportCloneClient() {
const fetchSources = useCallback(async () => {
try {
const res = await fetch("/api/admin/import/clone?action=sources");
const res = await adminFetch("/api/admin/import/clone?action=sources");
const data = await res.json();
if (res.ok) {
setSources(data.sources || []);
@@ -18,6 +18,7 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
import { getAvatarUrl } from "@/lib/imager";
import { adminFetch } from "@/lib/admin-fetch";
// ── Shared types ─────────────────────────────────────────────────────────────
@@ -50,7 +51,7 @@ async function runSseImport(
onDone: (label: string) => void,
onComplete: (succeeded: number, failed: number) => void,
): Promise<void> {
const res = await fetch(url, {
const res = await adminFetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
@@ -155,7 +156,7 @@ function LibsView() {
try {
const params = new URLSearchParams({ page: String(pageNum) });
if (search) params.set("search", search);
const res = await fetch(`/api/admin/import/clothing?${params}`);
const res = await adminFetch(`/api/admin/import/clothing?${params}`);
const data = await res.json();
if (!res.ok) {
setError(data.error || "Failed to load");
@@ -251,7 +252,7 @@ function LibsView() {
async function remove(p: FigureItem) {
setBusyLib(p.lib);
try {
const res = await fetch(
const res = await adminFetch(
`/api/admin/import/clothing?lib=${encodeURIComponent(p.lib)}`,
{
method: "DELETE",
@@ -532,7 +533,7 @@ function SetsView() {
try {
const params = new URLSearchParams({ page: String(pageNum) });
if (search) params.set("search", search);
const res = await fetch(`/api/admin/import/clothing/sets?${params}`);
const res = await adminFetch(`/api/admin/import/clothing/sets?${params}`);
const data = await res.json();
if (!res.ok) {
setError(data.error || "Failed to load");
@@ -19,6 +19,7 @@ import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
import { adminFetch } from "@/lib/admin-fetch";
interface EffectItem {
id: string;
@@ -50,7 +51,7 @@ export function ImportEffectsClient() {
try {
const params = new URLSearchParams();
if (search) params.set("search", search);
const res = await fetch(`/api/admin/import/effects?${params}`);
const res = await adminFetch(`/api/admin/import/effects?${params}`);
const data = await res.json();
if (!res.ok) {
setError(data.error || "Failed to load");
@@ -101,7 +102,7 @@ export function ImportEffectsClient() {
async function importViaSse(items: EffectItem[]) {
setBatchProgress({ done: 0, total: items.length });
const res = await fetch("/api/admin/import/effects/batch", {
const res = await adminFetch("/api/admin/import/effects/batch", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ items, concurrency: 3 }),
@@ -167,7 +168,7 @@ export function ImportEffectsClient() {
async function remove(e: EffectItem) {
setBusyLib(e.lib);
try {
const res = await fetch(
const res = await adminFetch(
`/api/admin/import/effects?lib=${encodeURIComponent(e.lib)}`,
{
method: "DELETE",
@@ -52,6 +52,7 @@ import {
SelectValue,
} from "@/components/ui/select";
import { NitroEditorDialog } from "./nitro-editor-dialog";
import { adminFetch } from "@/lib/admin-fetch";
interface FurniItem {
id: number;
@@ -222,7 +223,7 @@ export function ImportFurniClient() {
const fetchStats = useCallback(async () => {
try {
const res = await fetch("/api/admin/import/furni?action=stats");
const res = await adminFetch("/api/admin/import/furni?action=stats");
const data = await res.json();
if (res.ok) {
setStats({
@@ -327,7 +328,7 @@ export function ImportFurniClient() {
if (status === "missing-nitro") {
params.set("action", "missing-nitro");
}
const res = await fetch(`/api/admin/import/furni?${params}`);
const res = await adminFetch(`/api/admin/import/furni?${params}`);
const data = await res.json();
if (!res.ok) {
setError(data.error || "Failed to load");
@@ -417,7 +418,7 @@ export function ImportFurniClient() {
async function doImport(item: FurniItem) {
setImportingId(item.classname);
try {
const res = await fetch("/api/admin/import/furni", {
const res = await adminFetch("/api/admin/import/furni", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
@@ -483,7 +484,7 @@ export function ImportFurniClient() {
setBatchProgress(new Map(initialProgress));
try {
const res = await fetch("/api/admin/import/furni/batch", {
const res = await adminFetch("/api/admin/import/furni/batch", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
@@ -590,7 +591,7 @@ export function ImportFurniClient() {
async function regenNitro(item: FurniItem) {
setRegeneratingNitro((prev) => new Set(prev).add(item.classname));
try {
const res = await fetch("/api/admin/import/furni", {
const res = await adminFetch("/api/admin/import/furni", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
@@ -640,7 +641,7 @@ export function ImportFurniClient() {
setRegenProgress(new Map(initialProgress));
try {
const res = await fetch("/api/admin/import/furni/batch-regen", {
const res = await adminFetch("/api/admin/import/furni/batch-regen", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
@@ -753,7 +754,7 @@ export function ImportFurniClient() {
async function startReorganize() {
setReorganizing(true);
try {
const previewRes = await fetch("/api/admin/import/furni?dryrun=1", {
const previewRes = await adminFetch("/api/admin/import/furni?dryrun=1", {
method: "PUT",
});
const previewData = await previewRes.json();
@@ -779,7 +780,7 @@ export function ImportFurniClient() {
setReorgPreview(null);
setReorganizing(true);
try {
const res = await fetch("/api/admin/import/furni", { method: "PUT" });
const res = await adminFetch("/api/admin/import/furni", { method: "PUT" });
const d = await res.json();
if (res.ok) {
const parts: string[] = [];
@@ -31,6 +31,7 @@ import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import { adminFetch } from "@/lib/admin-fetch";
interface NitroEditorDialogProps {
classname: string;
@@ -121,7 +122,7 @@ export function NitroEditorDialog({
setLoading(true);
setJsonError(null);
try {
const res = await fetch(
const res = await adminFetch(
`/api/admin/import/furni/nitro-editor?classname=${encodeURIComponent(classname)}`,
);
if (!res.ok) {
@@ -189,7 +190,7 @@ export function NitroEditorDialog({
setSaving(true);
try {
const res = await fetch("/api/admin/import/furni/nitro-editor", {
const res = await adminFetch("/api/admin/import/furni/nitro-editor", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
@@ -17,6 +17,7 @@ import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
import { adminFetch } from "@/lib/admin-fetch";
interface PetItem {
lib: string;
@@ -57,7 +58,7 @@ export function ImportPetsClient() {
try {
const params = new URLSearchParams();
if (search) params.set("search", search);
const res = await fetch(`/api/admin/import/pets?${params}`);
const res = await adminFetch(`/api/admin/import/pets?${params}`);
const data = await res.json();
if (!res.ok) {
setError(data.error || "Failed to load");
@@ -99,7 +100,7 @@ export function ImportPetsClient() {
async function importViaSse(items: PetItem[]) {
setBatchProgress({ done: 0, total: items.length });
const res = await fetch("/api/admin/import/pets/batch", {
const res = await adminFetch("/api/admin/import/pets/batch", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ items, concurrency: 3 }),
@@ -161,7 +162,7 @@ export function ImportPetsClient() {
async function remove(p: PetItem) {
setBusyLib(p.lib);
try {
const res = await fetch(
const res = await adminFetch(
`/api/admin/import/pets?lib=${encodeURIComponent(p.lib)}`,
{
method: "DELETE",