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
415 lines
14 KiB
TypeScript
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>
|
|
</>
|
|
);
|
|
}
|