feat(admin): disable and re-enable customer accounts (#33)
Adds customers.disabled_at, admin disable/enable endpoints, a Status column and toggle on the Customers tab, and enforcement across every path that authenticates. Enforcement lives in attachCustomer, which previously validated only the session token and its expiry and never read the customer row. Register, login and password reset all mint sessions, so a single check in the middleware covers every path rather than three separate ones — and it means an existing rd_session cookie stops working at once instead of at its 30-day expiry. Disabling also deletes the sessions outright, so eviction does not wait for the next request. Disabling releases the items the customer was holding, in the same transaction. A disabled account cannot check out, so leaving its reservations would keep one-of-a-kind stock off the storefront for up to the cart expiry window for no purpose. Guarded on 'reserved' so a sold item is never resurrected. Re-enabling restores sign-in but does not give the items back — they may since have sold. Sign-in returns an explicit 403 rather than a generic credential failure. That does confirm the address has an account, which sits awkwardly beside the deliberately non-enumerating reset in #32; the trade was made the other way because a disabled customer told "invalid email or password" resets their password, succeeds, is still locked out, and concludes the site is broken. The check runs only after the password verifies, so it is not a bulk membership oracle, and /register already reveals existence. A reset token issued before the disable no longer mints a session, and no new tokens are issued for a disabled account — while still answering 200, so that endpoint stays non-enumerating. Self-service GDPR export and deletion are blocked along with everything else, so those requests now need servicing by hand. Worth checking the privacy policy does not promise unconditional self-service. Also fixes an unrelated bug the e2e run surfaced: the admin inventory fired a request per keystroke in the price fields with no sequencing, so an older response could land after a newer one and repaint stale rows. Only the most recently issued request may now set state. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
Layout, Table, Button, Form, Input, InputNumber, Upload, Modal,
|
||||
Space, Tag, Typography, Switch, message, Image as AntImage, theme, Tabs,
|
||||
@@ -39,7 +39,16 @@ function Inventory() {
|
||||
const [filters, setFilters] = useState<ItemFilters>(EMPTY_FILTERS);
|
||||
const { mode } = useThemeMode();
|
||||
|
||||
const load = (active: ItemFilters = filters) => fetchAdminItems(active).then(setItems);
|
||||
// Typing in the price fields fires a request per keystroke, so responses can
|
||||
// arrive out of order and an older one can repaint stale rows over a newer
|
||||
// result. Only the most recently issued request is allowed to set state.
|
||||
const latestRequest = useRef(0);
|
||||
const load = (active: ItemFilters = filters) => {
|
||||
const seq = ++latestRequest.current;
|
||||
return fetchAdminItems(active).then(rows => {
|
||||
if (seq === latestRequest.current) setItems(rows);
|
||||
});
|
||||
};
|
||||
|
||||
// The item form needs the current category tree and tag list; both change
|
||||
// from the sibling tabs, so they're refetched whenever the modal opens.
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Table, Drawer, Descriptions, Tag, Typography, Spin, Empty, Modal, Butto
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import {
|
||||
fetchCustomers, fetchCustomerDetail, fetchReservedItems, releaseReservedItem,
|
||||
setCustomerDisabled,
|
||||
CustomerSummary, CustomerDetail, ReservedItem
|
||||
} from './adminCustomersApi';
|
||||
|
||||
@@ -18,6 +19,7 @@ export default function Customers() {
|
||||
const [reserved, setReserved] = useState<ReservedItem[]>([]);
|
||||
const [reservedLoading, setReservedLoading] = useState(false);
|
||||
const [releasing, setReleasing] = useState<number | null>(null);
|
||||
const [togglingId, setTogglingId] = useState<number | null>(null);
|
||||
|
||||
function load() {
|
||||
return fetchCustomers().then(rows => { setCustomers(rows); setLoading(false); });
|
||||
@@ -46,6 +48,46 @@ export default function Customers() {
|
||||
}
|
||||
}
|
||||
|
||||
function handleToggleDisabled(customer: CustomerSummary) {
|
||||
const disabling = !customer.disabled_at;
|
||||
|
||||
Modal.confirm({
|
||||
title: disabling ? `Disable ${customer.email}?` : `Re-enable ${customer.email}?`,
|
||||
content: disabling ? (
|
||||
<span>
|
||||
They will be signed out everywhere immediately and told the account is disabled if they
|
||||
try to sign in.
|
||||
{customer.reserved_count > 0 && (
|
||||
<> Their {customer.reserved_count} reserved item
|
||||
{customer.reserved_count === 1 ? '' : 's'} will be released back to the storefront.</>
|
||||
)}
|
||||
{' '}Self-service data export and account deletion stop working too, so any such request
|
||||
has to be handled by hand.
|
||||
</span>
|
||||
) : (
|
||||
<span>
|
||||
They will be able to sign in again. Items released when the account was disabled are not
|
||||
returned — those may already have sold.
|
||||
</span>
|
||||
),
|
||||
okText: disabling ? 'Disable' : 'Re-enable',
|
||||
okButtonProps: { danger: disabling },
|
||||
onOk: async () => {
|
||||
setTogglingId(customer.id);
|
||||
try {
|
||||
await setCustomerDisabled(customer.id, disabling);
|
||||
} catch (err) {
|
||||
message.error(`Couldn't ${disabling ? 'disable' : 're-enable'} — ${(err as Error).message}`);
|
||||
return;
|
||||
} finally {
|
||||
setTogglingId(null);
|
||||
}
|
||||
message.success(disabling ? 'Account disabled' : 'Account re-enabled');
|
||||
load();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function handleRelease(item: ReservedItem) {
|
||||
if (!reservedFor) return;
|
||||
setReleasing(item.item_id);
|
||||
@@ -90,6 +132,14 @@ export default function Customers() {
|
||||
onFilter: (value, row) => row.marketing_consent === value,
|
||||
render: (v: boolean) => <Tag color={v ? 'blue' : 'default'}>{v ? 'Yes' : 'No'}</Tag>
|
||||
},
|
||||
{
|
||||
title: 'Status',
|
||||
dataIndex: 'disabled_at',
|
||||
render: (disabledAt: string | null) =>
|
||||
disabledAt
|
||||
? <Tag color="red">DISABLED</Tag>
|
||||
: <Tag color="green">ACTIVE</Tag>
|
||||
},
|
||||
{
|
||||
title: 'Reserved',
|
||||
dataIndex: 'reserved_count',
|
||||
@@ -127,6 +177,21 @@ export default function Customers() {
|
||||
sorter: (a, b) => new Date(a.last_order_at || 0).getTime() - new Date(b.last_order_at || 0).getTime(),
|
||||
render: (v: string | null) => (v ? new Date(v).toLocaleDateString() : '—')
|
||||
},
|
||||
{
|
||||
title: '',
|
||||
key: 'actions',
|
||||
render: (_: unknown, customer: CustomerSummary) => (
|
||||
<Button
|
||||
size="small"
|
||||
danger={!customer.disabled_at}
|
||||
loading={togglingId === customer.id}
|
||||
// The row opens the detail drawer, so this must not bubble.
|
||||
onClick={(event) => { event.stopPropagation(); handleToggleDisabled(customer); }}
|
||||
>
|
||||
{customer.disabled_at ? 'Re-enable' : 'Disable'}
|
||||
</Button>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: 'Joined',
|
||||
dataIndex: 'created_at',
|
||||
|
||||
@@ -9,6 +9,7 @@ export interface CustomerSummary {
|
||||
total_spent_cents: number;
|
||||
last_order_at: string | null;
|
||||
reserved_count: number;
|
||||
disabled_at: string | null;
|
||||
}
|
||||
|
||||
export interface ReservedItem {
|
||||
@@ -69,3 +70,15 @@ export async function releaseReservedItem(customerId: number, itemId: number): P
|
||||
throw new Error(detail.error || 'failed to release item');
|
||||
}
|
||||
}
|
||||
|
||||
export async function setCustomerDisabled(customerId: number, disabled: boolean): Promise<void> {
|
||||
const res = await fetch(`/api/admin/customers/${customerId}/${disabled ? 'disable' : 'enable'}`, {
|
||||
method: 'POST'
|
||||
});
|
||||
// Reporting success for a disable that failed would leave an account the
|
||||
// admin believes is locked still fully usable.
|
||||
if (!res.ok) {
|
||||
const detail = await res.json().catch(() => ({}));
|
||||
throw new Error(detail.error || `failed to ${disabled ? 'disable' : 'enable'} account`);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user