changemaker.lite/admin/src/pages/sms/SmsTemplatesPage.tsx
bunker-admin 5a0c4641a1 Security audit fixes, mobile responsiveness across 40+ admin pages
Security hardening from Mar 31 audit:
- Separate login rate limit (10/15min) from general auth budget (15/15min)
- Timing-safe webhook secret comparison (Listmonk)
- Docs file creation ACL check (matches PUT/DELETE guards)
- Key separation warnings for GITEA_SSO_SECRET and SERVICE_PASSWORD_SALT
- Clear GITEA_ADMIN_PASSWORD from .env after auto-setup
- SQL injection prevention in effectiveness groupBy (pre-validated map)
- Token hashing for password reset and verification tokens

Mobile responsiveness (Phase 2C):
- Add MobilePageHeader component and useMobile hook
- Responsive table columns (hide secondary cols on mobile)
- scroll={{ x: 'max-content' }} across all data tables
- Mobile-adapted layouts for Dashboard, Settings, Calendar, SMS, Social pages
- Conditional toolbar buttons on mobile viewports

Infrastructure:
- Updated docker-compose and nginx templates
- Build script and mirror script updates

Bunker Admin
2026-03-31 18:30:17 -06:00

415 lines
14 KiB
TypeScript

import { useState, useEffect, useCallback, useMemo } from 'react';
import { Table, Button, Modal, Drawer, Form, Input, Select, Space, Tag, App, Typography, Switch, Tooltip } from 'antd';
import { PlusOutlined, EditOutlined, CopyOutlined, DeleteOutlined, StarFilled, StarOutlined } from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table';
import { api } from '@/lib/api';
import type { SmsMessageTemplate, SmsPaginatedResponse } from '@/types/sms';
import type { AppOutletContext } from '@/types/api';
import { useMobile } from '@/hooks/useMobile';
import { useOutletContext } from 'react-router-dom';
import { useDebounce } from '@/hooks/useDebounce';
const { TextArea } = Input;
const { Text } = Typography;
const CATEGORY_COLORS: Record<string, string> = {
notification: 'blue',
campaign: 'purple',
custom: 'green',
};
/** Known placeholder sample values for live preview */
const SAMPLE_VALUES: Record<string, string> = {
name: 'Jane Doe',
phone: '+1 555 000 0000',
shiftTitle: 'Ward 6 Canvass',
shiftTime: '2:00 PM',
shiftDate: 'Mar 15',
shiftLocation: 'Community Centre',
organizationName: 'Changemaker',
};
/** Extract {var} names from a template string */
function extractVars(template: string): string[] {
const vars: string[] = [];
const regex = /\{(\w+)\}/g;
let m;
while ((m = regex.exec(template)) !== null) {
const v = m[1] as string;
if (!vars.includes(v)) vars.push(v);
}
return vars;
}
/** Calculate SMS segment count */
function segmentCount(length: number): number {
if (length === 0) return 0;
if (length <= 160) return 1;
return Math.ceil(length / 153);
}
/** Render a live preview with sample substitutions */
function renderPreview(template: string): string {
return template.replace(/\{(\w+)\}/g, (match, key) => SAMPLE_VALUES[key] ?? match);
}
export default function SmsTemplatesPage() {
const { setPageHeader } = useOutletContext<AppOutletContext>();
const { message } = App.useApp();
const { isMobile } = useMobile();
const [templates, setTemplates] = useState<SmsMessageTemplate[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(true);
// Filters
const [search, setSearch] = useState('');
const debouncedSearch = useDebounce(search, 300);
const [categoryFilter, setCategoryFilter] = useState<string | undefined>();
const [favoritesOnly, setFavoritesOnly] = useState(false);
// Drawer
const [drawerOpen, setDrawerOpen] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [form] = Form.useForm();
const [saving, setSaving] = useState(false);
// Live template text for character counter + variable extraction
const [liveTemplate, setLiveTemplate] = useState('');
useEffect(() => {
setPageHeader({ title: 'SMS Templates', subtitle: 'Manage reusable SMS message templates' });
}, [setPageHeader]);
const fetchTemplates = useCallback(async () => {
setLoading(true);
try {
const params: Record<string, string | number> = { page, limit: 50 };
if (debouncedSearch) params.search = debouncedSearch;
if (categoryFilter) params.category = categoryFilter;
if (favoritesOnly) params.isFavorite = 'true';
const { data } = await api.get<SmsPaginatedResponse<SmsMessageTemplate>>('/sms/templates', { params });
setTemplates(data.items);
setTotal(data.total);
} finally {
setLoading(false);
}
}, [page, debouncedSearch, categoryFilter, favoritesOnly]);
useEffect(() => { fetchTemplates(); }, [fetchTemplates]);
// Reset to page 1 when filters change
useEffect(() => { setPage(1); }, [debouncedSearch, categoryFilter, favoritesOnly]);
const closeDrawer = () => { setDrawerOpen(false); setLiveTemplate(''); };
const openCreate = () => {
setEditingId(null);
form.resetFields();
setLiveTemplate('');
setDrawerOpen(true);
};
const openEdit = (record: SmsMessageTemplate) => {
setEditingId(record.id);
form.setFieldsValue({
name: record.name,
template: record.template,
description: record.description || '',
category: record.category || undefined,
});
setLiveTemplate(record.template);
setDrawerOpen(true);
};
const openDuplicate = (record: SmsMessageTemplate) => {
setEditingId(null);
form.setFieldsValue({
name: `${record.name} (copy)`,
template: record.template,
description: record.description || '',
category: record.category || undefined,
});
setLiveTemplate(record.template);
setDrawerOpen(true);
};
const handleSave = async (values: { name: string; template: string; description?: string; category?: string }) => {
setSaving(true);
try {
if (editingId) {
await api.put(`/sms/templates/${editingId}`, values);
message.success('Template updated');
} else {
await api.post('/sms/templates', values);
message.success('Template created');
}
closeDrawer();
form.resetFields();
fetchTemplates();
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : 'Save failed';
message.error(msg);
} finally {
setSaving(false);
}
};
const handleDelete = async (id: string) => {
try {
await api.delete(`/sms/templates/${id}`);
message.success('Template deleted');
fetchTemplates();
} catch {
message.error('Delete failed — system templates cannot be deleted');
}
};
const handleToggleFavorite = async (id: string) => {
try {
await api.post(`/sms/templates/${id}/favorite`);
fetchTemplates();
} catch {
message.error('Failed to toggle favorite');
}
};
// Computed values for drawer
const liveVars = useMemo(() => extractVars(liveTemplate), [liveTemplate]);
const livePreview = useMemo(() => renderPreview(liveTemplate), [liveTemplate]);
const charCount = liveTemplate.length;
const segments = segmentCount(charCount);
const columns: ColumnsType<SmsMessageTemplate> = [
{
title: 'Name',
dataIndex: 'name',
ellipsis: true,
render: (name, record) => (
<Space>
<Tooltip title={record.isFavorite ? 'Remove from favorites' : 'Add to favorites'}>
<span style={{ cursor: 'pointer' }} onClick={() => handleToggleFavorite(record.id)}>
{record.isFavorite ? <StarFilled style={{ color: '#faad14' }} /> : <StarOutlined style={{ color: 'rgba(255,255,255,0.3)' }} />}
</span>
</Tooltip>
<span>{name}</span>
{record.isSystem && <Tag color="geekblue">SYSTEM</Tag>}
</Space>
),
},
{
title: 'Category',
dataIndex: 'category',
width: 120,
render: (cat) => cat ? <Tag color={CATEGORY_COLORS[cat] || 'default'}>{cat}</Tag> : <Text type="secondary">-</Text>,
},
{
title: 'Variables',
width: 200,
responsive: ['lg'] as any,
render: (_, record) => (
<Space wrap size={2}>
{(record.variables || []).map((v) => (
<Tag key={v} style={{ fontSize: 11 }}>{`{${v}}`}</Tag>
))}
{(!record.variables || record.variables.length === 0) && <Text type="secondary">-</Text>}
</Space>
),
},
{
title: 'Uses',
dataIndex: 'usageCount',
width: 70,
align: 'center',
responsive: ['md'] as any,
},
{
title: 'Updated',
dataIndex: 'updatedAt',
width: 100,
responsive: ['md'] as any,
render: (d) => {
const diff = Date.now() - new Date(d).getTime();
const mins = Math.floor(diff / 60000);
if (mins < 60) return `${mins}m ago`;
const hours = Math.floor(mins / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
return `${days}d ago`;
},
},
{
title: 'Actions',
width: 140,
render: (_, record) => (
<Space>
<Tooltip title="Edit">
<Button size="small" icon={<EditOutlined />} onClick={() => openEdit(record)} />
</Tooltip>
<Tooltip title="Duplicate">
<Button size="small" icon={<CopyOutlined />} onClick={() => openDuplicate(record)} />
</Tooltip>
{!record.isSystem && (
<Tooltip title="Delete">
<Button
size="small"
danger
icon={<DeleteOutlined />}
onClick={() => {
Modal.confirm({
title: 'Delete template?',
content: `This will permanently delete "${record.name}".`,
okText: 'Delete',
okType: 'danger',
onOk: () => handleDelete(record.id),
});
}}
/>
</Tooltip>
)}
</Space>
),
},
];
const drawerWidth = isMobile ? '100%' : 480;
return (
<>
<div style={{ marginRight: drawerOpen && !isMobile ? 480 : 0, transition: 'margin-right 0.15s cubic-bezier(0.2, 0, 0, 1)' }}>
<Space style={{ marginBottom: 16 }} wrap>
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
New Template
</Button>
<Input.Search
placeholder="Search templates..."
value={search}
onChange={(e) => setSearch(e.target.value)}
allowClear
style={{ width: 220 }}
/>
<Select
placeholder="Category"
value={categoryFilter}
onChange={setCategoryFilter}
allowClear
style={{ width: 140 }}
options={[
{ value: 'notification', label: 'Notification' },
{ value: 'campaign', label: 'Campaign' },
{ value: 'custom', label: 'Custom' },
]}
/>
<Space>
<Switch size="small" checked={favoritesOnly} onChange={setFavoritesOnly} />
<Text type="secondary" style={{ fontSize: 12 }}>Favorites only</Text>
</Space>
</Space>
<Table
rowKey="id"
columns={columns}
dataSource={templates}
loading={loading}
pagination={{ current: page, total, pageSize: 50, onChange: setPage, showSizeChanger: false }}
size="middle"
scroll={{ x: 'max-content' }}
/>
</div>
<Drawer
title={editingId ? 'Edit Template' : 'New Template'}
open={drawerOpen}
onClose={closeDrawer}
destroyOnHidden
mask={false}
width={drawerWidth}
rootStyle={{ position: 'absolute', top: 64, height: 'calc(100vh - 64px)' }}
extra={
<Space>
<Button onClick={closeDrawer}>Cancel</Button>
<Button type="primary" loading={saving} onClick={() => form.submit()}>
{editingId ? 'Save' : 'Create'}
</Button>
</Space>
}
>
<Form form={form} layout="vertical" onFinish={handleSave}>
<Form.Item name="name" label="Name" rules={[{ required: true, message: 'Template name is required' }]}>
<Input placeholder="e.g. shift-reminder-custom" maxLength={200} />
</Form.Item>
<Form.Item
name="template"
label="Message Template"
rules={[{ required: true, message: 'Template body is required' }]}
extra={
<Space style={{ marginTop: 4 }}>
<Text type="secondary" style={{ fontSize: 12 }}>
{charCount} / 1,600 chars
</Text>
<Text type="secondary" style={{ fontSize: 12 }}>
{segments} SMS segment{segments !== 1 ? 's' : ''}
</Text>
{charCount > 160 && (
<Text type="warning" style={{ fontSize: 12 }}>
Multi-part message (153 chars/segment)
</Text>
)}
</Space>
}
>
<TextArea
rows={4}
maxLength={1600}
placeholder="Hi {name}, your shift {shiftTitle} is coming up on {shiftDate} at {shiftTime}."
onChange={(e) => setLiveTemplate(e.target.value)}
/>
</Form.Item>
{/* Live variables */}
{liveVars.length > 0 && (
<div style={{ marginBottom: 16 }}>
<Text type="secondary" style={{ fontSize: 12 }}>Detected variables: </Text>
{liveVars.map((v) => (
<Tag key={v} color="blue" style={{ fontSize: 11 }}>{`{${v}}`}</Tag>
))}
</div>
)}
{/* Live preview */}
{liveTemplate && (
<div style={{
background: 'rgba(255,255,255,0.05)',
padding: 12,
borderRadius: 8,
marginBottom: 16,
border: '1px solid rgba(255,255,255,0.1)',
}}>
<Text type="secondary" style={{ fontSize: 11, display: 'block', marginBottom: 6 }}>Preview:</Text>
<Text style={{ fontSize: 13, fontFamily: 'monospace', whiteSpace: 'pre-wrap' }}>
{livePreview}
</Text>
</div>
)}
<Form.Item name="description" label="Description">
<TextArea rows={2} maxLength={500} placeholder="Internal notes about when to use this template" />
</Form.Item>
<Form.Item name="category" label="Category">
<Select
placeholder="Select category"
allowClear
options={[
{ value: 'notification', label: 'Notification' },
{ value: 'campaign', label: 'Campaign' },
{ value: 'custom', label: 'Custom' },
]}
/>
</Form.Item>
</Form>
</Drawer>
</>
);
}