Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions frontend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -66,3 +66,12 @@ GMAIL_USER=

# --- Security
CSRF_SECRET=

CHECKOUT_RATE_LIMIT_MAX=10
CHECKOUT_RATE_LIMIT_WINDOW_SECONDS=300

STRIPE_WEBHOOK_INVALID_SIG_RL_MAX=30
STRIPE_WEBHOOK_INVALID_SIG_RL_WINDOW_SECONDS=60

# emergency switch
RATE_LIMIT_DISABLED=0
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { db } from '@/db';
import { products, productPrices } from '@/db/schema';
import type { CurrencyCode } from '@/lib/shop/currency';
import { currencyValues } from '@/lib/shop/currency';
import { issueCsrfToken } from '@/lib/security/csrf';

export const dynamic = 'force-dynamic';

Expand Down Expand Up @@ -75,6 +76,7 @@ export default async function EditProductPage({
: parseMajorToMinor(product.originalPrice),
},
];
const csrfToken = issueCsrfToken('admin:products:update');

return (
<>
Expand All @@ -83,6 +85,7 @@ export default async function EditProductPage({
<ProductForm
mode="edit"
productId={product.id}
csrfToken={csrfToken}
initialValues={{
title: product.title,
slug: product.slug,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ type ProductFormProps = {
mode: 'create' | 'edit';
productId?: string;
initialValues?: Partial<ProductAdminInput> & { imageUrl?: string };
csrfToken: string;
};

type ApiResponse = {
Expand Down Expand Up @@ -128,6 +129,7 @@ export function ProductForm({
mode,
productId,
initialValues,
csrfToken,
}: ProductFormProps) {
const router = useRouter();

Expand Down Expand Up @@ -374,13 +376,21 @@ export function ProductForm({
if (imageFile) {
formData.append('image', imageFile);
}
if (!csrfToken) {
setError('Security token missing. Refresh the page and retry.');
setIsSubmitting(false);
return;
}

const response = await fetch(
mode === 'create'
? '/api/shop/admin/products'
: `/api/shop/admin/products/${productId}`,
{
method: mode === 'create' ? 'POST' : 'PATCH',
headers: {
'x-csrf-token': csrfToken,
},
body: formData,
}
);
Expand Down Expand Up @@ -411,6 +421,13 @@ export function ProductForm({
setError(data.error ?? msg);
return;
}
if (
response.status === 403 &&
(data.code === 'CSRF_MISSING' || data.code === 'CSRF_INVALID')
) {
setError('Security token expired. Refresh the page and retry.');
return;
}

setError(
data.error ??
Expand Down
4 changes: 3 additions & 1 deletion frontend/app/[locale]/shop/admin/products/new/page.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,21 @@
// frontend/app/[locale]/shop/admin/products/new/page.tsx
import { ShopAdminTopbar } from '@/components/shop/admin/shop-admin-topbar';
import { guardShopAdminPage } from '@/lib/auth/guard-shop-admin-page';
import { issueCsrfToken } from '@/lib/security/csrf';

import { ProductForm } from '../_components/product-form';

export const dynamic = 'force-dynamic';

export default async function NewProductPage() {
await guardShopAdminPage();
const csrfToken = issueCsrfToken('admin:products:create');

return (
<>
<ShopAdminTopbar />
<main className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
<ProductForm mode="create" />
<ProductForm mode="create" csrfToken={csrfToken} />
</main>
</>
);
Expand Down
4 changes: 3 additions & 1 deletion frontend/app/[locale]/shop/admin/products/page.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// frontend/app/[locale]/shop/admin/products/page.tsx
import { Link } from '@/i18n/routing';
import { and, desc, eq } from 'drizzle-orm';

import { issueCsrfToken } from '@/lib/security/csrf';
import { ShopAdminTopbar } from '@/components/shop/admin/shop-admin-topbar';
import { guardShopAdminPage } from '@/lib/auth/guard-shop-admin-page';

Expand Down Expand Up @@ -67,6 +67,7 @@ export default async function AdminProductsPage({

const hasNext = all.length > PAGE_SIZE;
const rows = all.slice(0, PAGE_SIZE);
const csrfTokenStatus = issueCsrfToken('admin:products:status');

return (
<>
Expand Down Expand Up @@ -251,6 +252,7 @@ export default async function AdminProductsPage({
<AdminProductStatusToggle
id={row.id}
initialIsActive={row.isActive}
csrfToken={csrfTokenStatus}
/>
</div>
</td>
Expand Down
4 changes: 4 additions & 0 deletions frontend/app/api/shop/admin/orders/[id]/refund/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
AdminUnauthorizedError,
requireAdminApi,
} from '@/lib/auth/admin';
import { requireAdminCsrf } from '@/lib/security/admin-csrf';

import { logError } from '@/lib/logging';
import { OrderNotFoundError, InvalidPayloadError } from '@/lib/services/errors';
Expand All @@ -18,6 +19,9 @@ export async function POST(
) {
try {
await requireAdminApi(request);
const csrfRes = requireAdminCsrf(request, 'admin:orders:refund');
if (csrfRes) return csrfRes;
Comment on lines 21 to +23

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Provide CSRF token for refund requests

The refund endpoint now hard-requires requireAdminCsrf, but the only caller (frontend/app/[locale]/shop/admin/orders/[id]/RefundButton.tsx) still posts without x-csrf-token or csrfToken form field. That means every admin refund will now return 403 CSRF_MISSING even for valid admins, effectively breaking refunds in the UI. Either pass an issued token into RefundButton and set the header, or allow this specific endpoint to accept the existing request format.

Useful? React with 👍 / 👎.


const rawParams = await context.params;
const parsed = orderIdParamSchema.safeParse(rawParams);

Expand Down
11 changes: 11 additions & 0 deletions frontend/app/api/shop/admin/products/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
SlugConflictError,
PriceConfigError,
} from '@/lib/services/errors';
import { requireAdminCsrf } from '@/lib/security/admin-csrf';

import { parseAdminProductForm } from '@/lib/admin/parseAdminProductForm';
import { logError } from '@/lib/logging';
Expand Down Expand Up @@ -148,6 +149,13 @@ export async function PATCH(
}

const formData = await request.formData();
const csrfRes = requireAdminCsrf(
request,
'admin:products:update',
formData
);
if (csrfRes) return csrfRes;

// PATCH inside PATCH() right after: const formData = await request.formData();
const saleViolationFromForm = getSaleViolationFromFormData(formData);

Expand Down Expand Up @@ -305,6 +313,9 @@ export async function DELETE(
): Promise<NextResponse> {
try {
await requireAdminApi(request);
const csrfRes = requireAdminCsrf(request, 'admin:products:delete');
if (csrfRes) return csrfRes;

const rawParams = await context.params;
const parsedParams = productIdParamSchema.safeParse(rawParams);

Expand Down
3 changes: 3 additions & 0 deletions frontend/app/api/shop/admin/products/[id]/status/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
AdminUnauthorizedError,
requireAdminApi,
} from '@/lib/auth/admin';
import { requireAdminCsrf } from '@/lib/security/admin-csrf';

import { logError } from '@/lib/logging';
import { toggleProductStatus } from '@/lib/services/products';
Expand All @@ -18,6 +19,8 @@ export async function PATCH(
) {
try {
await requireAdminApi(request);
const csrfRes = requireAdminCsrf(request, 'admin:products:status');
if (csrfRes) return csrfRes;

const rawParams = await context.params;
const parsedParams = productIdParamSchema.safeParse(rawParams);
Expand Down
8 changes: 8 additions & 0 deletions frontend/app/api/shop/admin/products/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
AdminUnauthorizedError,
requireAdminApi,
} from '@/lib/auth/admin';
import { requireAdminCsrf } from '@/lib/security/admin-csrf';

import { parseAdminProductForm } from '@/lib/admin/parseAdminProductForm';
import { logError } from '@/lib/logging';
Expand Down Expand Up @@ -82,6 +83,13 @@ export async function POST(request: NextRequest) {
await requireAdminApi(request);

const formData = await request.formData();
const csrfRes = requireAdminCsrf(
request,
'admin:products:create',
formData
);
if (csrfRes) return csrfRes;

const imageFile = formData.get('image');
if (!(imageFile instanceof File) || imageFile.size === 0) {
return NextResponse.json(
Expand Down
24 changes: 23 additions & 1 deletion frontend/app/api/shop/checkout/route.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { NextRequest, NextResponse } from 'next/server';

import {
enforceRateLimit,
getClientIp,
rateLimitResponse,
} from '@/lib/security/rate-limit';
import { getCurrentUser } from '@/lib/auth';
import { isPaymentsEnabled } from '@/lib/env/stripe';
import { logError, logWarn } from '@/lib/logging';
Expand Down Expand Up @@ -227,6 +231,24 @@ export async function POST(request: NextRequest) {
);
}
}
// P1: rate limit checkout (cross-instance, DB-backed)
// Policy: allow reasonable retries; block abusive burst.
const checkoutSubject = sessionUserId ?? getClientIp(request) ?? 'anon';

const decision = await enforceRateLimit({
key: `checkout:${checkoutSubject}`,
limit: Number(process.env.CHECKOUT_RATE_LIMIT_MAX ?? 10),
windowSeconds: Number(
process.env.CHECKOUT_RATE_LIMIT_WINDOW_SECONDS ?? 300
),
});

if (!decision.ok) {
return rateLimitResponse({
retryAfterSeconds: decision.retryAfterSeconds,
details: { scope: 'checkout' },
});
}
Comment on lines +234 to +251

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Guard rate-limit env parsing to avoid NaN/zero defaults.

If CHECKOUT_RATE_LIMIT_MAX or CHECKOUT_RATE_LIMIT_WINDOW_SECONDS is unset or non-numeric, Number(...) can yield NaN or 0, which may block all requests or disable limits unexpectedly (Line 240–243). Consider validating and falling back to sane defaults.

🔧 Suggested fix
-  const decision = await enforceRateLimit({
-    key: `checkout:${checkoutSubject}`,
-    limit: Number(process.env.CHECKOUT_RATE_LIMIT_MAX ?? 10),
-    windowSeconds: Number(
-      process.env.CHECKOUT_RATE_LIMIT_WINDOW_SECONDS ?? 300
-    ),
-  });
+  const limitRaw = Number(process.env.CHECKOUT_RATE_LIMIT_MAX);
+  const windowRaw = Number(process.env.CHECKOUT_RATE_LIMIT_WINDOW_SECONDS);
+  const limit = Number.isFinite(limitRaw) && limitRaw > 0 ? limitRaw : 10;
+  const windowSeconds =
+    Number.isFinite(windowRaw) && windowRaw > 0 ? windowRaw : 300;
+
+  const decision = await enforceRateLimit({
+    key: `checkout:${checkoutSubject}`,
+    limit,
+    windowSeconds,
+  });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// P1: rate limit checkout (cross-instance, DB-backed)
// Policy: allow reasonable retries; block abusive burst.
const checkoutSubject = sessionUserId ?? getClientIp(request) ?? 'anon';
const decision = await enforceRateLimit({
key: `checkout:${checkoutSubject}`,
limit: Number(process.env.CHECKOUT_RATE_LIMIT_MAX ?? 10),
windowSeconds: Number(
process.env.CHECKOUT_RATE_LIMIT_WINDOW_SECONDS ?? 300
),
});
if (!decision.ok) {
return rateLimitResponse({
retryAfterSeconds: decision.retryAfterSeconds,
details: { scope: 'checkout' },
});
}
// P1: rate limit checkout (cross-instance, DB-backed)
// Policy: allow reasonable retries; block abusive burst.
const checkoutSubject = sessionUserId ?? getClientIp(request) ?? 'anon';
const limitRaw = Number(process.env.CHECKOUT_RATE_LIMIT_MAX);
const windowRaw = Number(process.env.CHECKOUT_RATE_LIMIT_WINDOW_SECONDS);
const limit = Number.isFinite(limitRaw) && limitRaw > 0 ? limitRaw : 10;
const windowSeconds =
Number.isFinite(windowRaw) && windowRaw > 0 ? windowRaw : 300;
const decision = await enforceRateLimit({
key: `checkout:${checkoutSubject}`,
limit,
windowSeconds,
});
if (!decision.ok) {
return rateLimitResponse({
retryAfterSeconds: decision.retryAfterSeconds,
details: { scope: 'checkout' },
});
}
🤖 Prompt for AI Agents
In `@frontend/app/api/shop/checkout/route.ts` around lines 234 - 251, The env
parsing for CHECKOUT_RATE_LIMIT_MAX and CHECKOUT_RATE_LIMIT_WINDOW_SECONDS can
produce NaN/0; update the code that builds the enforceRateLimit call (the
variables passed for limit and windowSeconds) to parse and validate the env
values (use parseInt/Number and check Number.isFinite and >0), and fall back to
sane defaults (e.g., 10 and 300) when the env is missing, non-numeric, or <=0;
keep the checkoutSubject logic as-is and pass the validated numeric values into
enforceRateLimit.


try {
const result = await createOrderWithItems({
Expand Down
37 changes: 37 additions & 0 deletions frontend/app/api/shop/webhooks/stripe/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,11 @@ import {
appendRefundToMeta,
} from '@/lib/services/orders/psp-metadata/refunds';
import { markStripeAttemptFinal } from '@/lib/services/orders/payment-attempts';
import {
enforceRateLimit,
getClientIp,
rateLimitResponse,
} from '@/lib/security/rate-limit';

const REFUND_FULLNESS_UNDETERMINED = 'REFUND_FULLNESS_UNDETERMINED' as const;

Expand Down Expand Up @@ -312,6 +317,22 @@ export async function POST(request: NextRequest) {

const signature = request.headers.get('stripe-signature');
if (!signature) {
const ip = getClientIp(request) ?? 'anon';
const decision = await enforceRateLimit({
key: `stripe_webhook:missing_sig:${ip}`,
Comment on lines +320 to +322

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, find the implementation of getClientIp
find . -type f -name "*.ts" -o -name "*.tsx" -o -name "*.js" | head -20

Repository: DevLoversTeam/devlovers.net

Length of output: 934


🏁 Script executed:

# Search for getClientIp implementation
rg "function getClientIp|const getClientIp|export.*getClientIp" --type ts --type tsx --type js

Repository: DevLoversTeam/devlovers.net

Length of output: 98


🏁 Script executed:

# Look at the Stripe webhook route file
wc -l frontend/app/api/shop/webhooks/stripe/route.ts

Repository: DevLoversTeam/devlovers.net

Length of output: 122


🏁 Script executed:

# Read the specific lines mentioned in the review
sed -n '310,330p' frontend/app/api/shop/webhooks/stripe/route.ts

Repository: DevLoversTeam/devlovers.net

Length of output: 746


🏁 Script executed:

# Also check lines 356-358
sed -n '350,365p' frontend/app/api/shop/webhooks/stripe/route.ts

Repository: DevLoversTeam/devlovers.net

Length of output: 551


🏁 Script executed:

# Search for where enforceRateLimit and getClientIp are imported from
head -50 frontend/app/api/shop/webhooks/stripe/route.ts

Repository: DevLoversTeam/devlovers.net

Length of output: 1748


🏁 Script executed:

# Find the rate-limit utility file
find . -path "*/lib/security/rate-limit*" -type f

Repository: DevLoversTeam/devlovers.net

Length of output: 108


🏁 Script executed:

# Search for the rate-limit file more broadly
fd "rate-limit" --type f

Repository: DevLoversTeam/devlovers.net

Length of output: 106


🏁 Script executed:

# Search for the lib/security directory
ls -la frontend/lib/security/ 2>/dev/null || echo "Path not found, searching..."

Repository: DevLoversTeam/devlovers.net

Length of output: 370


🏁 Script executed:

# Search more broadly for getClientIp implementation
rg "getClientIp" --type ts --type js -A 5 -B 2

Repository: DevLoversTeam/devlovers.net

Length of output: 3896


🏁 Script executed:

# Read the full rate-limit.ts file to see the complete getClientIp implementation
cat -n frontend/lib/security/rate-limit.ts

Repository: DevLoversTeam/devlovers.net

Length of output: 5200


IP-spoofing risk requires deployment boundary verification.

getClientIp checks cf-connecting-ip first (good), but falls back to x-forwarded-for which clients can spoof if not behind a trusted proxy. If your deployment is behind Cloudflare or a reverse proxy that overwrites these headers, this is safe. If the webhook endpoint is exposed directly to untrusted clients relying on x-forwarded-for, rate limits can be bypassed by rotating the header.

Action: Document your deployment boundary (e.g., "deployed behind Cloudflare" or "reverse proxy at X-Real-IP-trusted boundary") or add explicit configuration to specify trusted proxy headers. Consider adding IP validation or rejecting requests from untrusted sources.

Also applies to: 356-358

🤖 Prompt for AI Agents
In `@frontend/app/api/shop/webhooks/stripe/route.ts` around lines 320 - 322, The
code uses getClientIp() (used when building the rate-limit key for
enforceRateLimit and the `stripe_webhook:missing_sig:${ip}` logic) which falls
back to spoofable headers; ensure the webhook only trusts proxy-provided
addresses by adding a deployment-configured trusted-proxy/headers check (e.g.,
require Cloudflare/CF-Connecting-IP or a configured TRUSTED_PROXY flag) or
reject requests that only provide x-forwarded-for; update getClientIp usage (and
the related logic around enforceRateLimit and the other occurrence at lines
~356-358) to consult that trusted-proxy config before accepting X-Forwarded-For,
and document the requirement (“must be deployed behind Cloudflare or reverse
proxy that overwrites X-Forwarded-For”) in deployment docs.

limit: Number(process.env.STRIPE_WEBHOOK_INVALID_SIG_RL_MAX ?? 30),
windowSeconds: Number(
process.env.STRIPE_WEBHOOK_INVALID_SIG_RL_WINDOW_SECONDS ?? 60
),
Comment on lines +323 to +326

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Guard against NaN in env‑driven limits.

Number(process.env...) can produce NaN (e.g., misconfigured env), which then propagates into SQL and can error or effectively disable limits. Prefer a safe integer parser with a fallback.

🛠️ Proposed fix
+function readIntEnv(name: string, fallback: number) {
+  const raw = process.env[name];
+  const parsed = Number.parseInt(raw ?? '', 10);
+  return Number.isFinite(parsed) ? parsed : fallback;
+}
@@
-    const decision = await enforceRateLimit({
-      key: `stripe_webhook:missing_sig:${ip}`,
-      limit: Number(process.env.STRIPE_WEBHOOK_INVALID_SIG_RL_MAX ?? 30),
-      windowSeconds: Number(
-        process.env.STRIPE_WEBHOOK_INVALID_SIG_RL_WINDOW_SECONDS ?? 60
-      ),
-    });
+    const decision = await enforceRateLimit({
+      key: `stripe_webhook:missing_sig:${ip}`,
+      limit: readIntEnv('STRIPE_WEBHOOK_INVALID_SIG_RL_MAX', 30),
+      windowSeconds: readIntEnv(
+        'STRIPE_WEBHOOK_INVALID_SIG_RL_WINDOW_SECONDS',
+        60
+      ),
+    });
@@
-      const decision = await enforceRateLimit({
-        key: `stripe_webhook:invalid_sig:${ip}`,
-        limit: Number(process.env.STRIPE_WEBHOOK_INVALID_SIG_RL_MAX ?? 30),
-        windowSeconds: Number(
-          process.env.STRIPE_WEBHOOK_INVALID_SIG_RL_WINDOW_SECONDS ?? 60
-        ),
-      });
+      const decision = await enforceRateLimit({
+        key: `stripe_webhook:invalid_sig:${ip}`,
+        limit: readIntEnv('STRIPE_WEBHOOK_INVALID_SIG_RL_MAX', 30),
+        windowSeconds: readIntEnv(
+          'STRIPE_WEBHOOK_INVALID_SIG_RL_WINDOW_SECONDS',
+          60
+        ),
+      });

Also applies to: 359-362

🤖 Prompt for AI Agents
In `@frontend/app/api/shop/webhooks/stripe/route.ts` around lines 323 - 326, The
environment-driven numeric settings (STRIPE_WEBHOOK_INVALID_SIG_RL_MAX and
STRIPE_WEBHOOK_INVALID_SIG_RL_WINDOW_SECONDS) are converted with Number(...)
which can yield NaN; update the code that builds the rate-limit config (the
object with properties limit and windowSeconds) to robustly parse those env
values using a safe integer parser and fallback to the default (e.g., use
parseInt and Number.isFinite or Number.isInteger checks, or coerce with
Math.floor and isFinite) so if the env is invalid it falls back to 30 and 60
respectively; apply the same defensive parsing to the analogous block that sets
those env-driven limits later in the file (the second occurrence around the
STRIPE_WEBHOOK_INVALID_SIG_* variables).

});

if (!decision.ok) {
return rateLimitResponse({
retryAfterSeconds: decision.retryAfterSeconds,
details: { scope: 'stripe_webhook', reason: 'missing_signature' },
});
}

logError(
'Stripe webhook missing signature header',
new Error('MISSING_STRIPE_SIGNATURE')
Expand All @@ -332,6 +353,22 @@ export async function POST(request: NextRequest) {
error instanceof Error &&
error.message === 'STRIPE_INVALID_SIGNATURE'
) {
const ip = getClientIp(request) ?? 'anon';
const decision = await enforceRateLimit({
key: `stripe_webhook:invalid_sig:${ip}`,
limit: Number(process.env.STRIPE_WEBHOOK_INVALID_SIG_RL_MAX ?? 30),
windowSeconds: Number(
process.env.STRIPE_WEBHOOK_INVALID_SIG_RL_WINDOW_SECONDS ?? 60
),
});

if (!decision.ok) {
return rateLimitResponse({
retryAfterSeconds: decision.retryAfterSeconds,
details: { scope: 'stripe_webhook', reason: 'invalid_signature' },
});
}

logError('Stripe webhook signature verification failed', error);
return NextResponse.json({ code: 'INVALID_SIGNATURE' }, { status: 400 });
}
Expand Down
33 changes: 25 additions & 8 deletions frontend/components/shop/admin/admin-product-status-toggle.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,51 +5,68 @@ import { useId, useState } from 'react';
interface AdminProductStatusToggleProps {
id: string;
initialIsActive: boolean;
csrfToken: string;
}

export function AdminProductStatusToggle({
id,
initialIsActive,
csrfToken,
}: AdminProductStatusToggleProps) {
const [isActive, setIsActive] = useState(initialIsActive);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);

// For accessible association; stable per component instance.
const errorId = useId();

const toggleStatus = async () => {
setIsLoading(true);
setError(null);

if (!csrfToken) {
setError('Security token missing. Refresh the page.');
setIsLoading(false);
return;
}

try {
const response = await fetch(`/api/shop/admin/products/${id}/status`, {
method: 'PATCH',
headers: {
'x-csrf-token': csrfToken,
},
});

if (!response.ok) {
let code: string | undefined;
try {
const body = await response.json();
code = typeof body?.code === 'string' ? body.code : undefined;
} catch {
// ignore
}

if (response.status === 403 && (code === 'CSRF_MISSING' || code === 'CSRF_INVALID')) {
setError('Security token expired. Refresh the page and retry.');
return;
}

setError('Failed to update status');
return;
}

const data: { product?: { isActive?: boolean } } = await response.json();

if (typeof data.product?.isActive === 'boolean') {
setIsActive(data.product.isActive);
}
} catch {
// Avoid noisy console in UI components; keep UX deterministic.
setError('Failed to update status');
} finally {
setIsLoading(false);
}
};

const buttonLabel = isLoading
? 'Updating'
: isActive
? 'Deactivate'
: 'Activate';
const buttonLabel = isLoading ? 'Updating' : isActive ? 'Deactivate' : 'Activate';

return (
<div className="flex min-w-0 flex-col gap-1">
Expand Down
Loading