feat(admin): make the token lifetimes, cart hold and greeting configurable (#136)
Giving the customer emails a tab of their own (#135) made a family of related holes visible: an admin could edit the wording of every customer email, but not the facts that wording asserted, and two templates could not address the customer at all. Both token lifetimes were hardcoded twice. VERIFY_TOKEN_TTL_MS sat in routes/customers.ts while the verification body separately said "This link expires in 24 hours", and RESET_TOKEN_TTL_MS sat beside a body separately saying "one hour". The prose was not derived from the constant, it was a second hand-written copy of the same fact — so making the constants configurable without addressing that would have made things worse, not better: the setting moves to two hours and the email keeps confidently promising one. Both are now settings, and both templates state their lifetime through an {{expiresIn}} placeholder rendered from the setting. The cart reminder gains {{holdDuration}} for the same reason. Per-item deadlines were already inside {{itemList}}, but there was no way to write a sentence about the hold itself without hardcoding a number the cart expiry setting could change underneath the author. All three durations render through one formatDuration(), so the reset email and the cart reminder say "one hour" the same way rather than in two authors' phrasing. A fractional hour drops to minutes, because "0.5 hours" reads badly and "1.5 hours" reads worse in a sentence a customer is meant to act on. Every template now offers greeting, firstName and lastName. favoriteSold and favoriteWithdrawn previously offered only itemName and siteUrl and could not address anyone — the query behind them never selected a name, so it does now. The greeting itself is two settings, a format and a fallback, rather than the wording baked into greeting(). The fallback is separate rather than the format with the name edited out: that editing is guesswork that has to be right every time, and getting it wrong ships "Hi ," to everyone who registered while first names were still optional (#106). Those customers exist, which is why greeting() guarded the case in the first place. The admin preview renders the durations and the greeting from the live settings rather than from a static sample. The preview exists so an admin sees the email that will be sent, and a sample reading "one hour" while the setting says two is the precise failure these placeholders were added to remove. Settings are read through a new adminSettings accessor. cart_expiry_hours was previously read by an inline query in two places, each with its own `|| '24'`; with five settings and read sites across three routes and the cron job, a default written twice is a default that will eventually disagree with itself. Values are stored as text, so each definition declares how to read it back — numbers were the only kind until the greeting format arrived. The new placeholders are available but never required, so every template an admin has already saved keeps rendering and keeps sending. Also fixes updateAdminSettings announcing success for a save the server refused: it returned the 400 body as though it were the saved settings, so the form reported "Settings saved" either way. Closes #136
This commit is contained in:
@@ -5,6 +5,8 @@ import Button from 'antd/es/button';
|
||||
import Typography from 'antd/es/typography';
|
||||
import message from 'antd/es/message';
|
||||
import Card from 'antd/es/card';
|
||||
import Space from 'antd/es/space';
|
||||
import Input from 'antd/es/input';
|
||||
import { fetchAdminSettings, updateAdminSettings } from './adminSettingsApi';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
@@ -12,12 +14,11 @@ const { Title, Text } = Typography;
|
||||
export default function Settings() {
|
||||
const [form] = Form.useForm();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchAdminSettings()
|
||||
.then(s => {
|
||||
form.setFieldsValue({ cartExpiryHours: s.cartExpiryHours });
|
||||
})
|
||||
.then(s => form.setFieldsValue(s))
|
||||
// A rejection here used to leave the form spinning indefinitely.
|
||||
.catch(() => message.error('Could not load settings'))
|
||||
.finally(() => setLoading(false));
|
||||
@@ -25,22 +26,97 @@ export default function Settings() {
|
||||
|
||||
async function handleSave() {
|
||||
const values = await form.validateFields();
|
||||
await updateAdminSettings(values);
|
||||
message.success('Settings saved');
|
||||
setSaving(true);
|
||||
try {
|
||||
await updateAdminSettings(values);
|
||||
message.success('Settings saved');
|
||||
} catch (err) {
|
||||
// Reported rather than swallowed: the previous version announced success
|
||||
// whatever the server said.
|
||||
message.error((err as Error).message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
// One form across both cards, so Save commits every field rather than each
|
||||
// card needing a button of its own.
|
||||
return (
|
||||
<Card style={{ maxWidth: 480 }}>
|
||||
<Title level={4}>Cart Settings</Title>
|
||||
<Text type="secondary">
|
||||
How long an item stays reserved in a customer's cart before it's automatically released back to available inventory.
|
||||
</Text>
|
||||
<Form form={form} layout="vertical" style={{ marginTop: 16 }} disabled={loading}>
|
||||
<Form.Item name="cartExpiryHours" label="Cart expiry (hours)" rules={[{ required: true }]}>
|
||||
<InputNumber min={0.5} step={0.5} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Button type="primary" onClick={handleSave} loading={loading}>Save</Button>
|
||||
</Form>
|
||||
</Card>
|
||||
<Form form={form} layout="vertical" disabled={loading}>
|
||||
<Space direction="vertical" size={16} style={{ display: 'flex', maxWidth: 480 }}>
|
||||
<Card>
|
||||
<Title level={4}>Cart</Title>
|
||||
<Text type="secondary">
|
||||
How long an item stays reserved in a customer's cart before it's automatically released back to available inventory.
|
||||
</Text>
|
||||
<Form.Item
|
||||
name="cartExpiryHours"
|
||||
label="Cart expiry (hours)"
|
||||
rules={[{ required: true }]}
|
||||
style={{ marginTop: 16, marginBottom: 0 }}
|
||||
>
|
||||
<InputNumber min={0.5} step={0.5} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<Title level={4}>Link lifetimes</Title>
|
||||
{/* The emails state these durations from a placeholder, so changing a
|
||||
value here changes what the customer is told. That is the point:
|
||||
the wording used to be a second, hand-written copy of the number. */}
|
||||
<Text type="secondary">
|
||||
How long the links in the verification and password reset emails stay valid. The emails state these
|
||||
durations, so they follow whatever is set here.
|
||||
</Text>
|
||||
<Form.Item
|
||||
name="verifyTokenHours"
|
||||
label="Email verification link (hours)"
|
||||
rules={[{ required: true }]}
|
||||
style={{ marginTop: 16 }}
|
||||
>
|
||||
<InputNumber min={0.25} step={0.5} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="passwordResetHours"
|
||||
label="Password reset link (hours)"
|
||||
rules={[{ required: true }]}
|
||||
style={{ marginBottom: 0 }}
|
||||
>
|
||||
<InputNumber min={0.25} step={0.5} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<Title level={4}>Greeting</Title>
|
||||
{/* Two fields rather than one. Editing the name out of a format for
|
||||
customers who have none is guesswork that has to be right every
|
||||
time, and getting it wrong ships "Hi ," — the exact failure #106
|
||||
was about. An admin writes both and neither is guessed. */}
|
||||
<Text type="secondary">
|
||||
What <code>{'{{greeting}}'}</code> becomes in every email. Use <code>{'{{firstName}}'}</code> and{' '}
|
||||
<code>{'{{lastName}}'}</code> in the format. The fallback is used whole for customers who registered
|
||||
without a first name.
|
||||
</Text>
|
||||
<Form.Item
|
||||
name="greetingFormat"
|
||||
label="Greeting format"
|
||||
rules={[{ required: true, message: 'A greeting format is required' }]}
|
||||
style={{ marginTop: 16 }}
|
||||
>
|
||||
<Input placeholder="Hi {{firstName}}," />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="greetingFallback"
|
||||
label="Fallback, when there is no first name"
|
||||
rules={[{ required: true, message: 'A fallback greeting is required' }]}
|
||||
style={{ marginBottom: 0 }}
|
||||
>
|
||||
<Input placeholder="Hi," />
|
||||
</Form.Item>
|
||||
</Card>
|
||||
|
||||
<Button type="primary" onClick={handleSave} loading={loading || saving}>Save</Button>
|
||||
</Space>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
export interface AdminSettings {
|
||||
cartExpiryHours: number;
|
||||
verifyTokenHours: number;
|
||||
passwordResetHours: number;
|
||||
greetingFormat: string;
|
||||
greetingFallback: string;
|
||||
}
|
||||
|
||||
export async function fetchAdminSettings(): Promise<AdminSettings> {
|
||||
const res = await fetch('/api/admin/settings');
|
||||
if (!res.ok) throw new Error('Could not load settings');
|
||||
return res.json();
|
||||
}
|
||||
|
||||
@@ -13,5 +18,10 @@ export async function updateAdminSettings(settings: AdminSettings): Promise<Admi
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(settings)
|
||||
});
|
||||
return res.json();
|
||||
const body = await res.json();
|
||||
// The refusal names the field that was rejected, and it is the only useful
|
||||
// thing to say. Without this check a 400 was returned as though it were the
|
||||
// saved settings, and the form reported success for a save the server refused.
|
||||
if (!res.ok) throw new Error(body?.error || 'Could not save settings');
|
||||
return body;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user