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
7 changes: 7 additions & 0 deletions frontend/.env.example
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# --- Core / Environment
APP_ADDITIONAL_ORIGINS=https://admin.example.test
APP_ENV=
APP_ORIGIN=https://example.test
APP_URL=
NEXT_PUBLIC_SITE_URL=

Expand Down Expand Up @@ -83,6 +85,11 @@ STRIPE_WEBHOOK_RL_WINDOW_SECONDS=60
STRIPE_WEBHOOK_INVALID_SIG_RL_MAX=30
STRIPE_WEBHOOK_INVALID_SIG_RL_WINDOW_SECONDS=60

# SECURITY: If true, trust Cloudflare's cf-connecting-ip header for rate limiting.
# Enable ONLY when traffic is fronted by Cloudflare (header is set by Cloudflare at the edge).
# Default: false (0). Keep 0 in untrusted environments to avoid IP spoofing.
TRUST_CF_CONNECTING_IP=0

# SECURITY: If true, trust x-real-ip / x-forwarded-for headers for rate limiting.
# Enable ONLY behind Cloudflare or a trusted reverse proxy that overwrites these headers.
# Default: false (empty/0/false).
Expand Down
6 changes: 5 additions & 1 deletion frontend/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -46,4 +46,8 @@ next-env.d.ts
# Documentation (only for development)
CLAUDE.md
docs/
.claude
.claude

!docs/
!docs/security/
!docs/security/origin-posture.md
265 changes: 175 additions & 90 deletions frontend/app/[locale]/shop/admin/orders/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,23 @@ export default async function AdminOrdersPage({
const hasNext = all.length > PAGE_SIZE;
const items = all.slice(0, PAGE_SIZE);

const viewModels = items.map(order => {
const currency = orderCurrency(order, locale);
const totalMinor = pickMinor(order?.totalAmountMinor, order?.totalAmount);

return {
id: order.id,
createdAt: formatDate(order.createdAt, locale),
paymentStatus: order.paymentStatus,
totalFormatted:
totalMinor === null ? '-' : formatMoney(totalMinor, currency, locale),
itemCount: order.itemCount,
paymentProvider: order.paymentProvider ?? '-',
viewHref: `/shop/admin/orders/${order.id}`,
viewAriaLabel: `View order ${order.id}`,
};
});

return (
<>
<ShopAdminTopbar />
Expand All @@ -69,7 +86,7 @@ export default async function AdminOrdersPage({
className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8"
aria-labelledby="admin-orders-title"
>
<header className="flex items-start justify-between gap-4">
<header className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<h1
id="admin-orders-title"
className="text-2xl font-bold text-foreground"
Expand All @@ -81,127 +98,195 @@ export default async function AdminOrdersPage({
<input type="hidden" name={CSRF_FORM_FIELD} value={csrfToken} />
<button
type="submit"
className="rounded-md border border-border px-3 py-1.5 text-sm font-medium text-foreground transition-colors hover:bg-secondary"
className="inline-flex w-full items-center justify-center rounded-md border border-border px-3 py-1.5 text-sm font-medium text-foreground transition-colors hover:bg-secondary sm:w-auto"
>
Reconcile stale
</button>
</form>
</header>

<section className="mt-6" aria-label="Orders table">
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-border text-sm">
<caption className="sr-only">Orders list</caption>

<thead className="bg-muted/50">
<tr>
<th
scope="col"
className="px-3 py-2 text-left font-semibold text-foreground"
>
Created
</th>
<th
scope="col"
className="px-3 py-2 text-left font-semibold text-foreground"
>
Status
</th>
<th
scope="col"
className="px-3 py-2 text-left font-semibold text-foreground"
<section className="mt-6" aria-label="Orders list">
{/* Mobile cards */}
<div className="md:hidden">
{viewModels.length === 0 ? (
<div className="rounded-md border border-border p-4 text-sm text-muted-foreground">
No orders yet.
</div>
) : (
<ul className="space-y-3">
{viewModels.map(vm => (
<li
key={vm.id}
className="rounded-lg border border-border bg-background p-4"
>
Total
</th>
<th
scope="col"
className="px-3 py-2 text-left font-semibold text-foreground"
>
Items
</th>
<th
scope="col"
className="px-3 py-2 text-left font-semibold text-foreground"
>
Provider
</th>
<th
scope="col"
className="px-3 py-2 text-left font-semibold text-foreground"
>
Order ID
</th>
<th
scope="col"
className="px-3 py-2 text-left font-semibold text-foreground"
>
Actions
</th>
</tr>
</thead>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="text-xs text-muted-foreground">
{vm.createdAt}
</div>
<div className="mt-1">
<span className="inline-flex rounded-full bg-muted px-2 py-1 text-xs font-medium text-foreground">
{vm.paymentStatus}
</span>
</div>
</div>

<div className="shrink-0 whitespace-nowrap text-right text-sm font-medium text-foreground">
{vm.totalFormatted}
</div>
</div>

<dl className="mt-3 grid grid-cols-2 gap-x-3 gap-y-2 text-xs">
<div>
<dt className="text-muted-foreground">Items</dt>
<dd className="text-foreground">{vm.itemCount}</dd>
</div>

<tbody className="divide-y divide-border">
{items.length === 0 ? (
<div className="min-w-0">
<dt className="text-muted-foreground">Provider</dt>
<dd
className="truncate text-foreground"
title={vm.paymentProvider}
>
{vm.paymentProvider}
</dd>
</div>

<div className="col-span-2">
<dt className="text-muted-foreground">Order ID</dt>
<dd
className="break-all font-mono text-[11px] text-muted-foreground"
title={vm.id}
>
{vm.id}
</dd>
</div>
</dl>

<div className="mt-3">
<Link
href={vm.viewHref}
className="inline-flex items-center justify-center rounded-md border border-border px-2 py-1 text-xs font-medium text-foreground transition-colors hover:bg-secondary"
aria-label={vm.viewAriaLabel}
>
View
</Link>
</div>
</li>
))}
</ul>
)}
</div>

{/* Desktop table */}
<div className="hidden md:block">
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-border text-sm">
<caption className="sr-only">Orders list</caption>

<thead className="bg-muted/50">
<tr>
<td className="px-3 py-6 text-muted-foreground" colSpan={7}>
No orders yet.
</td>
<th
scope="col"
className="px-3 py-2 text-left font-semibold text-foreground"
>
Created
</th>
<th
scope="col"
className="px-3 py-2 text-left font-semibold text-foreground"
>
Status
</th>
<th
scope="col"
className="px-3 py-2 text-left font-semibold text-foreground"
>
Total
</th>
<th
scope="col"
className="px-3 py-2 text-left font-semibold text-foreground"
>
Items
</th>
<th
scope="col"
className="px-3 py-2 text-left font-semibold text-foreground"
>
Provider
</th>
<th
scope="col"
className="px-3 py-2 text-left font-semibold text-foreground"
>
Order ID
</th>
<th
scope="col"
className="px-3 py-2 text-left font-semibold text-foreground"
>
Actions
</th>
</tr>
) : (
items.map(order => {
const currency = orderCurrency(order, locale);
const totalMinor = pickMinor(
order?.totalAmountMinor,
order?.totalAmount
);
const totalFormatted =
totalMinor === null
? '-'
: formatMoney(totalMinor, currency, locale);

return (
<tr key={order.id} className="hover:bg-muted/50">
<td className="px-3 py-2 text-muted-foreground">
{formatDate(order.createdAt, locale)}
</thead>

<tbody className="divide-y divide-border">
{viewModels.length === 0 ? (
<tr>
<td
className="px-3 py-6 text-muted-foreground"
colSpan={7}
>
No orders yet.
</td>
</tr>
) : (
viewModels.map(vm => (
<tr key={vm.id} className="hover:bg-muted/50">
<td className="px-3 py-2 text-muted-foreground whitespace-nowrap">
{vm.createdAt}
</td>

<td className="px-3 py-2">
<td className="px-3 py-2 whitespace-nowrap">
<span className="inline-flex rounded-full bg-muted px-2 py-1 text-xs font-medium text-foreground">
{order.paymentStatus}
{vm.paymentStatus}
</span>
</td>

<td className="px-3 py-2 text-foreground">
{totalFormatted}
<td className="px-3 py-2 text-foreground whitespace-nowrap">
{vm.totalFormatted}
</td>

<td className="px-3 py-2 text-muted-foreground">
{order.itemCount}
<td className="px-3 py-2 text-muted-foreground whitespace-nowrap">
{vm.itemCount}
</td>
<td className="px-3 py-2 text-muted-foreground">
{order.paymentProvider}

<td className="px-3 py-2 text-muted-foreground whitespace-nowrap">
{vm.paymentProvider}
</td>

<td className="px-3 py-2 font-mono text-xs text-muted-foreground break-all">
{order.id}
{vm.id}
</td>

<td className="px-3 py-2">
<td className="px-3 py-2 whitespace-nowrap">
<Link
href={`/shop/admin/orders/${order.id}`}
href={vm.viewHref}
className="rounded-md border border-border px-2 py-1 text-xs font-medium text-foreground transition-colors hover:bg-secondary"
aria-label={`View order ${order.id}`}
aria-label={vm.viewAriaLabel}
>
View
</Link>
</td>
</tr>
);
})
)}
</tbody>
</table>
))
)}
</tbody>
</table>
</div>
</div>

<div className="mt-4">
<AdminPagination
basePath="/shop/admin/orders"
Expand Down
Loading