diff --git a/admin/src/components/docs/DocsEditorToolbar.tsx b/admin/src/components/docs/DocsEditorToolbar.tsx new file mode 100644 index 00000000..f47a0a15 --- /dev/null +++ b/admin/src/components/docs/DocsEditorToolbar.tsx @@ -0,0 +1,129 @@ +import { useCallback } from 'react'; +import { Button, Dropdown, Tooltip } from 'antd'; +import { + BoldOutlined, + ItalicOutlined, + StrikethroughOutlined, + HighlightOutlined, + CodeOutlined, + FontSizeOutlined, + AlertOutlined, + PlusOutlined, + DownOutlined, + LinkOutlined, + FileMarkdownOutlined, + TableOutlined, +} from '@ant-design/icons'; +import type { editor as monacoEditor } from 'monaco-editor'; +import { SNIPPETS, PLATFORM_INSERT_IDS, applySnippet } from './mkdocs-snippets'; + +interface DocsEditorToolbarProps { + editorRef: React.RefObject; + monacoRef: React.RefObject; + /** If true, show platform-specific inserts (video card, donate, etc.) */ + showPlatformInserts?: boolean; + /** Custom handler for snippet IDs that need special treatment (modals, etc.) */ + onCustomSnippet?: (snippetId: string) => boolean; + /** Background color — defaults to transparent */ + background?: string; + /** Border color — defaults to rgba(255,255,255,0.08) */ + borderColor?: string; +} + +export default function DocsEditorToolbar({ + editorRef, + monacoRef, + showPlatformInserts = false, + onCustomSnippet, + background = 'transparent', + borderColor = 'rgba(255,255,255,0.08)', +}: DocsEditorToolbarProps) { + const handleSnippet = useCallback((snippetId: string) => { + if (onCustomSnippet?.(snippetId)) return; + + const snippet = SNIPPETS.find(s => s.id === snippetId); + if (!snippet || !editorRef.current || !monacoRef.current) return; + applySnippet(editorRef.current, snippet, monacoRef.current); + }, [editorRef, monacoRef, onCustomSnippet]); + + const insertSnippets = SNIPPETS.filter(s => + s.group === 'insert' && (showPlatformInserts || !PLATFORM_INSERT_IDS.has(s.id)) + ); + + const getInsertIcon = (id: string) => { + if (id === 'link') return ; + if (id === 'image') return ; + if (id === 'table') return ; + return ; + }; + + const btnStyle = { width: 26, height: 24 }; + + return ( +
+ + + + +
+ + s.group === 'heading').map(s => ({ key: s.id, label: s.label, icon: , onClick: () => handleSnippet(s.id) })) }} trigger={['click']}> + + + +
+ + s.group === 'admonition').map(s => ({ key: s.id, label: s.label, icon: , onClick: () => handleSnippet(s.id) })) }} trigger={['click']}> + + + + s.group === 'code').map(s => ({ key: s.id, label: s.label, icon: , onClick: () => handleSnippet(s.id) })) }} trigger={['click']}> + + + + ({ + key: s.id, + label: s.label, + icon: getInsertIcon(s.id), + onClick: () => handleSnippet(s.id), + })) }} trigger={['click']}> + + +
+ ); +} diff --git a/admin/src/components/docs/mkdocs-snippets.ts b/admin/src/components/docs/mkdocs-snippets.ts new file mode 100644 index 00000000..5d47f0db --- /dev/null +++ b/admin/src/components/docs/mkdocs-snippets.ts @@ -0,0 +1,117 @@ +import type { editor as monacoEditor } from 'monaco-editor'; + +export interface MkDocsSnippet { + id: string; + label: string; + group: 'formatting' | 'heading' | 'admonition' | 'code' | 'insert'; + type: 'wrap' | 'block' | 'insert'; + prefix?: string; + suffix?: string; + template?: string; + keybinding?: 'ctrl+b' | 'ctrl+i'; +} + +export const SNIPPETS: MkDocsSnippet[] = [ + // Formatting + { id: 'bold', label: 'Bold', group: 'formatting', type: 'wrap', prefix: '**', suffix: '**', keybinding: 'ctrl+b' }, + { id: 'italic', label: 'Italic', group: 'formatting', type: 'wrap', prefix: '*', suffix: '*', keybinding: 'ctrl+i' }, + { id: 'strikethrough', label: 'Strikethrough', group: 'formatting', type: 'wrap', prefix: '~~', suffix: '~~' }, + { id: 'highlight', label: 'Highlight', group: 'formatting', type: 'wrap', prefix: '==', suffix: '==' }, + { id: 'inline-code', label: 'Inline Code', group: 'formatting', type: 'wrap', prefix: '`', suffix: '`' }, + { id: 'kbd', label: 'Keyboard Key', group: 'formatting', type: 'wrap', prefix: '++', suffix: '++' }, + // Headings + { id: 'h1', label: 'Heading 1', group: 'heading', type: 'block', template: '# $CURSOR' }, + { id: 'h2', label: 'Heading 2', group: 'heading', type: 'block', template: '## $CURSOR' }, + { id: 'h3', label: 'Heading 3', group: 'heading', type: 'block', template: '### $CURSOR' }, + { id: 'h4', label: 'Heading 4', group: 'heading', type: 'block', template: '#### $CURSOR' }, + // Admonitions + ...(['note', 'warning', 'tip', 'danger', 'info', 'success', 'question', 'abstract', 'example', 'bug', 'quote'] as const).map((t) => ({ + id: `admonition-${t}`, + label: `${t.charAt(0).toUpperCase() + t.slice(1)}`, + group: 'admonition' as const, + type: 'block' as const, + template: `!!! ${t} "Title"\n Content here`, + })), + { id: 'admonition-collapsible-open', label: 'Collapsible (open)', group: 'admonition', type: 'block', template: '???+ note "Title"\n Content here' }, + { id: 'admonition-collapsible-closed', label: 'Collapsible (closed)', group: 'admonition', type: 'block', template: '??? note "Title"\n Content here' }, + // Code + { id: 'code-block', label: 'Code Block', group: 'code', type: 'block', template: '```python\n$CURSOR\n```' }, + { id: 'code-annotated', label: 'Annotated Code', group: 'code', type: 'block', template: '```python\ncode # (1)!\n```\n\n1. Annotation' }, + { id: 'mermaid', label: 'Mermaid Diagram', group: 'code', type: 'block', template: '```mermaid\ngraph LR\n A --> B\n```' }, + // Inserts (standard markdown — no auth required) + { id: 'link', label: 'Link', group: 'insert', type: 'wrap', prefix: '[', suffix: '](url)' }, + { id: 'image', label: 'Image', group: 'insert', type: 'insert', template: '![Alt text](image.png)' }, + { id: 'button', label: 'Button', group: 'insert', type: 'insert', template: '[Text](url){ .md-button }' }, + { id: 'button-primary', label: 'Primary Button', group: 'insert', type: 'insert', template: '[Text](url){ .md-button .md-button--primary }' }, + { id: 'icon', label: 'Material Icon', group: 'insert', type: 'insert', template: ':material-icon-name:' }, + { id: 'table', label: 'Table', group: 'insert', type: 'insert', template: '| Column 1 | Column 2 | Column 3 |\n| -------- | -------- | -------- |\n| Cell 1 | Cell 2 | Cell 3 |\n| Cell 4 | Cell 5 | Cell 6 |' }, + { id: 'tasklist', label: 'Task List', group: 'insert', type: 'insert', template: '- [ ] Task 1\n- [ ] Task 2\n- [x] Done' }, + { id: 'tabs', label: 'Tabs', group: 'insert', type: 'insert', template: '=== "Tab 1"\n\n Content\n\n=== "Tab 2"\n\n Content' }, + { id: 'math-block', label: 'Math Block', group: 'insert', type: 'block', template: '$$\n$CURSOR\n$$' }, + { id: 'footnote', label: 'Footnote', group: 'insert', type: 'insert', template: '[^1]\n\n[^1]: Text' }, + { id: 'def-list', label: 'Definition List', group: 'insert', type: 'insert', template: 'Term\n: Definition' }, + { id: 'hr', label: 'Horizontal Rule', group: 'insert', type: 'insert', template: '---' }, + // Platform-specific inserts (require auth — handled by DocsPage modals) + { id: 'video-card', label: 'Video Card', group: 'insert', type: 'insert', template: '' }, + { id: 'photo-insert', label: 'Photo', group: 'insert', type: 'insert', template: '' }, + { id: 'donate-button', label: 'Donate Button', group: 'insert', type: 'insert', template: '' }, + { id: 'pricing-table', label: 'Pricing Table', group: 'insert', type: 'insert', template: '' }, + { id: 'product-card', label: 'Product Card', group: 'insert', type: 'insert', template: '' }, + { id: 'ad-insert', label: 'Ad', group: 'insert', type: 'insert', template: '' }, + { id: 'scheduling-poll', label: 'Scheduling Poll', group: 'insert', type: 'insert', template: '' }, + { id: 'wiki-link', label: 'Wiki Link [[]]', group: 'insert', type: 'insert', template: '' }, +]; + +/** IDs of insert snippets that require authenticated API access (modal-based) */ +export const PLATFORM_INSERT_IDS = new Set([ + 'video-card', 'photo-insert', 'donate-button', 'pricing-table', + 'product-card', 'ad-insert', 'scheduling-poll', 'wiki-link', +]); + +export function applySnippet( + ed: monacoEditor.IStandaloneCodeEditor, + snippet: MkDocsSnippet, + monaco: typeof import('monaco-editor'), +) { + const sel = ed.getSelection(); + const model = ed.getModel(); + if (!sel || !model) return; + + const selectedText = model.getValueInRange(sel); + + if (snippet.type === 'wrap' && snippet.prefix != null && snippet.suffix != null) { + if (selectedText) { + ed.executeEdits('mkdocs-snippet', [{ + range: sel, + text: snippet.prefix + selectedText + snippet.suffix, + }]); + } else { + const placeholder = 'text'; + ed.executeEdits('mkdocs-snippet', [{ + range: sel, + text: snippet.prefix + placeholder + snippet.suffix, + }]); + const pos = sel.getStartPosition(); + const startCol = pos.column + snippet.prefix.length; + ed.setSelection(new monaco.Selection(pos.lineNumber, startCol, pos.lineNumber, startCol + placeholder.length)); + } + } else if (snippet.type === 'block' && snippet.template) { + const pos = sel.getStartPosition(); + let text = snippet.template.replace('$CURSOR', selectedText); + const lineContent = model.getLineContent(pos.lineNumber); + if (pos.column > 1 && lineContent.substring(0, pos.column - 1).trim().length > 0) { + text = '\n' + text; + } + ed.executeEdits('mkdocs-snippet', [{ + range: sel, + text, + }]); + } else if (snippet.type === 'insert' && snippet.template) { + ed.executeEdits('mkdocs-snippet', [{ + range: sel, + text: snippet.template, + }]); + } + + ed.focus(); +} diff --git a/admin/src/components/payments/ProductInsertModal.tsx b/admin/src/components/payments/ProductInsertModal.tsx index 92f409de..dc8444b3 100644 --- a/admin/src/components/payments/ProductInsertModal.tsx +++ b/admin/src/components/payments/ProductInsertModal.tsx @@ -35,8 +35,8 @@ export function ProductInsertModal({ open, onClose, onInsert }: ProductInsertMod if (open && products.length === 0) { setLoading(true); setError(null); - axios.get('/api/payments/products') - .then(({ data }) => setProducts(data)) + axios.get('/api/payments/products', { params: { limit: 50 } }) + .then(({ data }) => setProducts(data.products)) .catch(() => setError('Failed to load products')) .finally(() => setLoading(false)); } diff --git a/admin/src/components/payments/ProductWidget.tsx b/admin/src/components/payments/ProductWidget.tsx index 811e2722..1350e1af 100644 --- a/admin/src/components/payments/ProductWidget.tsx +++ b/admin/src/components/payments/ProductWidget.tsx @@ -21,9 +21,9 @@ export function ProductWidget({ productSlug, buttonText = 'Buy Now' }: ProductWi return; } - axios.get('/api/payments/products') + axios.get('/api/payments/products', { params: { limit: 50 } }) .then(({ data }) => { - const found = (data as Product[]).find(p => p.slug === productSlug); + const found = (data.products as Product[]).find(p => p.slug === productSlug); if (found) { setProduct(found); } else { diff --git a/admin/src/pages/DocsPage.tsx b/admin/src/pages/DocsPage.tsx index 55b98231..6b6466b0 100644 --- a/admin/src/pages/DocsPage.tsx +++ b/admin/src/pages/DocsPage.tsx @@ -329,68 +329,9 @@ function filterTree(nodes: FileNode[], query: string): FileNode[] { return filtered; } -// --- MkDocs Snippet System --- - -interface MkDocsSnippet { - id: string; - label: string; - group: 'formatting' | 'heading' | 'admonition' | 'code' | 'insert'; - type: 'wrap' | 'block' | 'insert'; - prefix?: string; - suffix?: string; - template?: string; - keybinding?: 'ctrl+b' | 'ctrl+i'; -} - -const SNIPPETS: MkDocsSnippet[] = [ - // Formatting - { id: 'bold', label: 'Bold', group: 'formatting', type: 'wrap', prefix: '**', suffix: '**', keybinding: 'ctrl+b' }, - { id: 'italic', label: 'Italic', group: 'formatting', type: 'wrap', prefix: '*', suffix: '*', keybinding: 'ctrl+i' }, - { id: 'strikethrough', label: 'Strikethrough', group: 'formatting', type: 'wrap', prefix: '~~', suffix: '~~' }, - { id: 'highlight', label: 'Highlight', group: 'formatting', type: 'wrap', prefix: '==', suffix: '==' }, - { id: 'inline-code', label: 'Inline Code', group: 'formatting', type: 'wrap', prefix: '`', suffix: '`' }, - { id: 'kbd', label: 'Keyboard Key', group: 'formatting', type: 'wrap', prefix: '++', suffix: '++' }, - // Headings - { id: 'h1', label: 'Heading 1', group: 'heading', type: 'block', template: '# $CURSOR' }, - { id: 'h2', label: 'Heading 2', group: 'heading', type: 'block', template: '## $CURSOR' }, - { id: 'h3', label: 'Heading 3', group: 'heading', type: 'block', template: '### $CURSOR' }, - { id: 'h4', label: 'Heading 4', group: 'heading', type: 'block', template: '#### $CURSOR' }, - // Admonitions - ...(['note', 'warning', 'tip', 'danger', 'info', 'success', 'question', 'abstract', 'example', 'bug', 'quote'] as const).map((t) => ({ - id: `admonition-${t}`, - label: `${t.charAt(0).toUpperCase() + t.slice(1)}`, - group: 'admonition' as const, - type: 'block' as const, - template: `!!! ${t} "Title"\n Content here`, - })), - { id: 'admonition-collapsible-open', label: 'Collapsible (open)', group: 'admonition', type: 'block', template: '???+ note "Title"\n Content here' }, - { id: 'admonition-collapsible-closed', label: 'Collapsible (closed)', group: 'admonition', type: 'block', template: '??? note "Title"\n Content here' }, - // Code - { id: 'code-block', label: 'Code Block', group: 'code', type: 'block', template: '```python\n$CURSOR\n```' }, - { id: 'code-annotated', label: 'Annotated Code', group: 'code', type: 'block', template: '```python\ncode # (1)!\n```\n\n1. Annotation' }, - { id: 'mermaid', label: 'Mermaid Diagram', group: 'code', type: 'block', template: '```mermaid\ngraph LR\n A --> B\n```' }, - // Inserts - { id: 'link', label: 'Link', group: 'insert', type: 'wrap', prefix: '[', suffix: '](url)' }, - { id: 'image', label: 'Image', group: 'insert', type: 'insert', template: '![Alt text](image.png)' }, - { id: 'button', label: 'Button', group: 'insert', type: 'insert', template: '[Text](url){ .md-button }' }, - { id: 'button-primary', label: 'Primary Button', group: 'insert', type: 'insert', template: '[Text](url){ .md-button .md-button--primary }' }, - { id: 'icon', label: 'Material Icon', group: 'insert', type: 'insert', template: ':material-icon-name:' }, - { id: 'table', label: 'Table', group: 'insert', type: 'insert', template: '| Column 1 | Column 2 | Column 3 |\n| -------- | -------- | -------- |\n| Cell 1 | Cell 2 | Cell 3 |\n| Cell 4 | Cell 5 | Cell 6 |' }, - { id: 'tasklist', label: 'Task List', group: 'insert', type: 'insert', template: '- [ ] Task 1\n- [ ] Task 2\n- [x] Done' }, - { id: 'tabs', label: 'Tabs', group: 'insert', type: 'insert', template: '=== "Tab 1"\n\n Content\n\n=== "Tab 2"\n\n Content' }, - { id: 'math-block', label: 'Math Block', group: 'insert', type: 'block', template: '$$\n$CURSOR\n$$' }, - { id: 'footnote', label: 'Footnote', group: 'insert', type: 'insert', template: '[^1]\n\n[^1]: Text' }, - { id: 'def-list', label: 'Definition List', group: 'insert', type: 'insert', template: 'Term\n: Definition' }, - { id: 'video-card', label: 'Video Card', group: 'insert', type: 'insert', template: '' }, - { id: 'photo-insert', label: 'Photo', group: 'insert', type: 'insert', template: '' }, - { id: 'donate-button', label: 'Donate Button', group: 'insert', type: 'insert', template: '' }, - { id: 'pricing-table', label: 'Pricing Table', group: 'insert', type: 'insert', template: '' }, - { id: 'product-card', label: 'Product Card', group: 'insert', type: 'insert', template: '' }, - { id: 'ad-insert', label: 'Ad', group: 'insert', type: 'insert', template: '' }, - { id: 'scheduling-poll', label: 'Scheduling Poll', group: 'insert', type: 'insert', template: '' }, - { id: 'wiki-link', label: 'Wiki Link [[]]', group: 'insert', type: 'insert', template: '' }, - { id: 'hr', label: 'Horizontal Rule', group: 'insert', type: 'insert', template: '---' }, -]; +// --- MkDocs Snippet System (shared) --- +import { SNIPPETS, applySnippet as applySnippetShared } from '@/components/docs/mkdocs-snippets'; +import type { MkDocsSnippet } from '@/components/docs/mkdocs-snippets'; // --- Inline Donate Block Generator --- // Produces HTML with data-role attributes for hydration by payment-widgets.js. @@ -523,55 +464,7 @@ function generateInlineProductHtml(o: InlineProductOpts): string { return lines.filter(Boolean).join('\n'); } -function applySnippet( - ed: monacoEditor.IStandaloneCodeEditor, - snippet: MkDocsSnippet, - monaco: typeof import('monaco-editor'), -) { - const sel = ed.getSelection(); - const model = ed.getModel(); - if (!sel || !model) return; - - const selectedText = model.getValueInRange(sel); - - if (snippet.type === 'wrap' && snippet.prefix != null && snippet.suffix != null) { - if (selectedText) { - ed.executeEdits('mkdocs-snippet', [{ - range: sel, - text: snippet.prefix + selectedText + snippet.suffix, - }]); - } else { - const placeholder = 'text'; - ed.executeEdits('mkdocs-snippet', [{ - range: sel, - text: snippet.prefix + placeholder + snippet.suffix, - }]); - // Select the placeholder so user can type over it - const pos = sel.getStartPosition(); - const startCol = pos.column + snippet.prefix.length; - ed.setSelection(new monaco.Selection(pos.lineNumber, startCol, pos.lineNumber, startCol + placeholder.length)); - } - } else if (snippet.type === 'block' && snippet.template) { - const pos = sel.getStartPosition(); - let text = snippet.template.replace('$CURSOR', selectedText); - // If cursor is in the middle of a line, prepend newline - const lineContent = model.getLineContent(pos.lineNumber); - if (pos.column > 1 && lineContent.substring(0, pos.column - 1).trim().length > 0) { - text = '\n' + text; - } - ed.executeEdits('mkdocs-snippet', [{ - range: sel, - text, - }]); - } else if (snippet.type === 'insert' && snippet.template) { - ed.executeEdits('mkdocs-snippet', [{ - range: sel, - text: snippet.template, - }]); - } - - ed.focus(); -} +const applySnippet = applySnippetShared; /** Wrapper component so useDocsEditor() hook only runs on mobile */ function MobileDocsEditorWrapper() { @@ -1648,9 +1541,9 @@ export default function DocsPage() { {isSuperAdmin && ( <>
- - )} diff --git a/admin/src/pages/MkDocsSettingsPage.tsx b/admin/src/pages/MkDocsSettingsPage.tsx index 194c8d07..cb590867 100644 --- a/admin/src/pages/MkDocsSettingsPage.tsx +++ b/admin/src/pages/MkDocsSettingsPage.tsx @@ -304,14 +304,14 @@ export default function MkDocsSettingsPage() { const [configRes, filesRes, campaignsRes] = await Promise.all([ api.get('/docs/mkdocs-config'), api.get('/docs/files'), - api.get('/campaigns/public').catch(() => ({ data: [] as Campaign[] })), + api.get('/campaigns/public', { params: { limit: 50 } }).catch(() => ({ data: { campaigns: [] } })), ]); const content = configRes.data.content; setRawYaml(content); setOriginalYaml(content); setEditorYaml(content); setFileTree(filesRes.data); - setCampaigns(campaignsRes.data); + setCampaigns(campaignsRes.data.campaigns); // Parse for settings tab syncSettingsFromYaml(content); diff --git a/admin/src/pages/PangolinPage.tsx b/admin/src/pages/PangolinPage.tsx index 5d1840c3..0f23681f 100644 --- a/admin/src/pages/PangolinPage.tsx +++ b/admin/src/pages/PangolinPage.tsx @@ -12,7 +12,7 @@ import { api } from '@/lib/api'; import type { AppOutletContext } from '@/components/AppLayout'; import type { PangolinStatus, PangolinConfig, PangolinResource, PangolinNewtStatus, PangolinExitNode, - ResourceStatusResponse, ResourceStatusItem, SyncResult, + PangolinSite, ConnectSiteResult, ResourceStatusResponse, ResourceStatusItem, SyncResult, } from '@/types/api'; const { Text, Paragraph } = Typography; @@ -90,6 +90,9 @@ export default function PangolinPage() { const [resourceStatus, setResourceStatus] = useState(null); const [statusLoading, setStatusLoading] = useState(false); const [syncResult, setSyncResult] = useState(null); + const [orgSites, setOrgSites] = useState<(PangolinSite & { isCurrentSite?: boolean })[]>([]); + const [sitesLoading, setSitesLoading] = useState(false); + const [connectLoading, setConnectLoading] = useState(null); // siteId being connected useEffect(() => { setPageHeader({ title: 'Tunnel Management' }); @@ -155,6 +158,46 @@ export default function PangolinPage() { } }, [status?.configured, config?.siteId, fetchResourceStatus]); + // Fetch org sites when site ID is stale/missing (for site picker) + const fetchOrgSites = useCallback(async () => { + setSitesLoading(true); + try { + const res = await api.get<{ sites: (PangolinSite & { isCurrentSite?: boolean })[]; currentNewtId: string | null }>('/pangolin/sites'); + setOrgSites(res.data.sites); + } catch { + message.error('Failed to load sites from Pangolin'); + } finally { + setSitesLoading(false); + } + }, [message]); + + useEffect(() => { + // Load org sites when: configured but site ID is stale/missing (for site picker) + if (status?.configured && (status?.siteIdMismatch || !config?.siteId)) { + fetchOrgSites(); + } + }, [status?.configured, status?.siteIdMismatch, config?.siteId, fetchOrgSites]); + + const handleConnectSite = async (siteId: string) => { + setConnectLoading(siteId); + try { + const res = await api.post('/pangolin/connect-site', { siteId }); + if (res.data.success) { + message.success(res.data.message); + // Refresh everything after connecting + setTimeout(() => { + fetchData(); + fetchNewtStatus(); + }, 1500); + } + } catch (err: unknown) { + const msg = (err as { response?: { data?: { error?: { message?: string } } } })?.response?.data?.error?.message ?? 'Failed to connect to site'; + message.error(msg); + } finally { + setConnectLoading(null); + } + }; + // Fetch exit nodes for site creation useEffect(() => { if (status?.configured && !config?.siteId) { @@ -312,11 +355,105 @@ export default function PangolinPage() { {config?.orgId || 'Not set'} - {config?.siteId || 'Not set'} + + {config?.siteId || 'Not set'} + {status?.siteIdMismatch && ( + } color="warning">Stale + )} + {status?.siteIdValid === true && ( + } color="success">Valid + )} + + {/* Site Picker — shown when site ID is stale or mismatched */} + {isConfigured && status?.siteIdMismatch && ( + Site ID Mismatch}> + + + The site ID {config?.siteId} in your .env file + {status?.resolvedSiteId + ? <> does not match the detected site {status.resolvedSiteId}. + : <> was not found in the organization. + } + {' '}The Newt tunnel may still be working, but resource management (sync, status checks) will fail. + + Select the correct site below to fix this: +
+ } + style={{ marginBottom: 16 }} + /> + + dataSource={orgSites} + rowKey="siteId" + size="small" + loading={sitesLoading} + pagination={false} + columns={[ + { + title: 'Site Name', + dataIndex: 'name', + key: 'name', + render: (name: string, record) => ( + + {sanitizeText(name)} + {record.isCurrentSite && Matches Newt ID} + + ), + }, + { + title: 'Site ID', + dataIndex: 'siteId', + key: 'siteId', + render: (id: string) => {id}, + }, + ...(!isMobile ? [{ + title: 'Status', + key: 'online', + width: 100, + render: (_: unknown, record: PangolinSite) => + record.online + ? Online + : Offline, + }] : []), + ...(!isMobile ? [{ + title: 'Last Seen', + dataIndex: 'lastSeen', + key: 'lastSeen', + render: (d: string) => d ? new Date(d).toLocaleString() : '—', + }] : []), + { + title: '', + key: 'action', + width: 120, + render: (_: unknown, record: PangolinSite) => ( + + ), + }, + ]} + /> +
+ +
+ + )} + {/* Setup Card — shown when API credentials configured but no site yet */} {isConfigured && !config?.siteId && ( Automated Setup}> @@ -437,6 +574,68 @@ export default function PangolinPage() { }, ]} /> + + {/* Connect to existing site — shown when org already has sites */} + {orgSites.length > 0 && ( + <> + Or Connect to an Existing Site + + + dataSource={orgSites} + rowKey="siteId" + size="small" + loading={sitesLoading} + pagination={false} + columns={[ + { + title: 'Site Name', + dataIndex: 'name', + key: 'name', + render: (name: string, record) => ( + + {sanitizeText(name)} + {record.isCurrentSite && Matches Newt ID} + + ), + }, + { + title: 'Site ID', + dataIndex: 'siteId', + key: 'siteId', + render: (id: string) => {id}, + }, + ...(!isMobile ? [{ + title: 'Status', + key: 'online', + width: 100, + render: (_: unknown, record: PangolinSite) => + record.online + ? Online + : Offline, + }] : []), + { + title: '', + key: 'action', + width: 120, + render: (_: unknown, record: PangolinSite) => ( + + ), + }, + ]} + /> + + )} )} diff --git a/admin/src/pages/media/AdAnalyticsDashboardPage.tsx b/admin/src/pages/media/AdAnalyticsDashboardPage.tsx index 80c5c71d..2d1a31dd 100644 --- a/admin/src/pages/media/AdAnalyticsDashboardPage.tsx +++ b/admin/src/pages/media/AdAnalyticsDashboardPage.tsx @@ -309,11 +309,10 @@ export default function AdAnalyticsDashboardPage() { )} diff --git a/admin/src/pages/public/CampaignsListPage.tsx b/admin/src/pages/public/CampaignsListPage.tsx index cc19c65b..73a10dc1 100644 --- a/admin/src/pages/public/CampaignsListPage.tsx +++ b/admin/src/pages/public/CampaignsListPage.tsx @@ -19,6 +19,7 @@ import { Grid, Tooltip, Popover, + Pagination, message, theme, } from 'antd'; @@ -55,6 +56,8 @@ export default function CampaignsListPage() { const [campaigns, setCampaigns] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(false); + const [page, setPage] = useState(1); + const [total, setTotal] = useState(0); const { token } = theme.useToken(); const screens = Grid.useBreakpoint(); const isMobile = !screens.md; @@ -85,14 +88,15 @@ export default function CampaignsListPage() { useEffect(() => { fetchCampaigns(); - }, []); + }, [page]); // eslint-disable-line react-hooks/exhaustive-deps const fetchCampaigns = async () => { setLoading(true); setError(false); try { - const { data } = await axios.get('/api/campaigns/public'); - setCampaigns(data); + const { data } = await axios.get('/api/campaigns/public', { params: { page, limit: 20 } }); + setCampaigns(data.campaigns); + setTotal(data.pagination.total); } catch { setError(true); } finally { @@ -527,6 +531,18 @@ export default function CampaignsListPage() { )} + {total > 20 && ( +
+ { setPage(p); window.scrollTo({ top: 0, behavior: 'smooth' }); }} + showSizeChanger={false} + /> +
+ )} + setAuthModalOpen(false)} diff --git a/admin/src/pages/public/PagesIndexPage.tsx b/admin/src/pages/public/PagesIndexPage.tsx index 7a32dc62..b1a71624 100644 --- a/admin/src/pages/public/PagesIndexPage.tsx +++ b/admin/src/pages/public/PagesIndexPage.tsx @@ -1,6 +1,6 @@ import { useState, useEffect } from 'react'; import { Link } from 'react-router-dom'; -import { Typography, Card, Row, Col, Spin, Empty, Grid, theme } from 'antd'; +import { Typography, Card, Row, Col, Spin, Empty, Grid, Pagination, theme } from 'antd'; import { FileTextOutlined } from '@ant-design/icons'; import axios from 'axios'; import dayjs from 'dayjs'; @@ -19,6 +19,8 @@ interface ListedPage { export default function PagesIndexPage() { const [pages, setPages] = useState([]); const [loading, setLoading] = useState(true); + const [page, setPage] = useState(1); + const [total, setTotal] = useState(0); const { token } = theme.useToken(); const screens = Grid.useBreakpoint(); const isMobile = !screens.md; @@ -29,11 +31,15 @@ export default function PagesIndexPage() { }, [settings?.organizationName]); useEffect(() => { - axios.get('/api/pages/listed') - .then(({ data }) => setPages(data)) + setLoading(true); + axios.get('/api/pages/listed', { params: { page, limit: 20 } }) + .then(({ data }) => { + setPages(data.pages); + setTotal(data.pagination.total); + }) .catch(() => {}) .finally(() => setLoading(false)); - }, []); + }, [page]); if (loading) { return ( @@ -122,6 +128,18 @@ export default function PagesIndexPage() { ))} )} + + {total > 20 && ( +
+ { setPage(p); window.scrollTo({ top: 0, behavior: 'smooth' }); }} + showSizeChanger={false} + /> +
+ )} ); } diff --git a/admin/src/pages/public/PetitionsListPage.tsx b/admin/src/pages/public/PetitionsListPage.tsx index 6f592c67..0be0d7dc 100644 --- a/admin/src/pages/public/PetitionsListPage.tsx +++ b/admin/src/pages/public/PetitionsListPage.tsx @@ -1,6 +1,6 @@ import { useState, useEffect } from 'react'; import { Link } from 'react-router-dom'; -import { Typography, Card, Row, Col, Spin, Empty, Progress, Grid, theme } from 'antd'; +import { Typography, Card, Row, Col, Spin, Empty, Progress, Grid, Pagination, theme } from 'antd'; import { FileTextOutlined, TeamOutlined } from '@ant-design/icons'; import axios from 'axios'; import type { Petition } from '@/types/api'; @@ -11,22 +11,22 @@ const API = '/api'; export default function PetitionsListPage() { const [petitions, setPetitions] = useState([]); const [loading, setLoading] = useState(true); + const [page, setPage] = useState(1); + const [total, setTotal] = useState(0); const { token } = theme.useToken(); const screens = Grid.useBreakpoint(); const isMobile = !screens.md; useEffect(() => { - (async () => { - try { - const { data } = await axios.get(`${API}/petitions/public`); - setPetitions(data); - } catch { - /* ignore */ - } finally { - setLoading(false); - } - })(); - }, []); + setLoading(true); + axios.get(`${API}/petitions/public`, { params: { page, limit: 20 } }) + .then(({ data }) => { + setPetitions(data.petitions); + setTotal(data.pagination.total); + }) + .catch(() => {}) + .finally(() => setLoading(false)); + }, [page]); if (loading) return ; if (!petitions.length) return ; @@ -84,6 +84,18 @@ export default function PetitionsListPage() { ); })} + + {total > 20 && ( +
+ { setPage(p); window.scrollTo({ top: 0, behavior: 'smooth' }); }} + showSizeChanger={false} + /> +
+ )} ); } diff --git a/admin/src/pages/public/SharedDocEditorPage.tsx b/admin/src/pages/public/SharedDocEditorPage.tsx index fffaf73d..bcf6f615 100644 --- a/admin/src/pages/public/SharedDocEditorPage.tsx +++ b/admin/src/pages/public/SharedDocEditorPage.tsx @@ -14,6 +14,8 @@ import type { editor as monacoEditor } from 'monaco-editor'; import { MonacoBinding } from 'y-monaco'; import { useDocShareCollaboration } from '@/hooks/useDocShareCollaboration'; import { CollaboratorAvatars } from '@/components/docs/CollaboratorAvatars'; +import DocsEditorToolbar from '@/components/docs/DocsEditorToolbar'; +import { SNIPPETS, applySnippet } from '@/components/docs/mkdocs-snippets'; const { Header, Content } = Layout; const { Title, Text } = Typography; @@ -36,6 +38,7 @@ export default function SharedDocEditorPage() { const [pageState, setPageState] = useState({ status: 'loading' }); const monacoEditorRef = useRef(null); + const monacoRef = useRef(null); const monacoBindingRef = useRef(null); const [editorReady, setEditorReady] = useState(false); @@ -95,9 +98,23 @@ export default function SharedDocEditorPage() { }, [shareToken]); // Monaco editor mount handler - const handleEditorMount: OnMount = useCallback((editor) => { + const handleEditorMount: OnMount = useCallback((editor, monaco) => { monacoEditorRef.current = editor; + monacoRef.current = monaco; setEditorReady(true); + + // Register Ctrl+B / Ctrl+I keyboard shortcuts for markdown formatting + SNIPPETS.filter(s => s.keybinding).forEach(snippet => { + const kb = snippet.keybinding === 'ctrl+b' + ? monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyB + : monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyI; + editor.addAction({ + id: `mkdocs.${snippet.id}`, + label: snippet.label, + keybindings: [kb], + run: (ed) => applySnippet(ed as monacoEditor.IStandaloneCodeEditor, snippet, monaco), + }); + }); }, []); // MonacoBinding effect: binds Y.Text to Monaco editor when both are ready @@ -150,7 +167,7 @@ export default function SharedDocEditorPage() { }, }} > - + {/* Header */}
+
{!collab.active && (
)} - + {/* Formatting toolbar for editable markdown files */} + {shareData?.canEdit && shareData.documentPath.endsWith('.md') && ( + + )} + +
+ +
)} diff --git a/admin/src/pages/public/ShiftsPage.tsx b/admin/src/pages/public/ShiftsPage.tsx index eb127b07..11320d5a 100644 --- a/admin/src/pages/public/ShiftsPage.tsx +++ b/admin/src/pages/public/ShiftsPage.tsx @@ -10,6 +10,7 @@ import { message, Spin, Result, + Pagination, Grid, theme, } from 'antd'; @@ -57,6 +58,8 @@ export default function PublicShiftsPage() { const [shifts, setShifts] = useState([]); const [loading, setLoading] = useState(true); + const [page, setPage] = useState(1); + const [total, setTotal] = useState(0); const [signupModalOpen, setSignupModalOpen] = useState(false); const [selectedShift, setSelectedShift] = useState(null); const [relatedCampaigns, setRelatedCampaigns] = useState([]); @@ -74,13 +77,14 @@ export default function PublicShiftsPage() { useEffect(() => { fetchShifts(); - }, []); + }, [page]); // eslint-disable-line react-hooks/exhaustive-deps const fetchShifts = async () => { setLoading(true); try { - const { data } = await axios.get(`${apiBase}/map/shifts/public`); - setShifts(data); + const { data } = await axios.get(`${apiBase}/map/shifts/public`, { params: { page, limit: 20 } }); + setShifts(data.shifts); + setTotal(data.pagination.total); } catch { message.error('Failed to load volunteer opportunities'); } finally { @@ -239,6 +243,18 @@ export default function PublicShiftsPage() { )} + {total > 20 && ( +
+ { setPage(p); window.scrollTo({ top: 0, behavior: 'smooth' }); }} + showSizeChanger={false} + /> +
+ )} + {/* Related Campaigns */} diff --git a/admin/src/pages/public/ShopPage.tsx b/admin/src/pages/public/ShopPage.tsx index a5219efe..bf9d075f 100644 --- a/admin/src/pages/public/ShopPage.tsx +++ b/admin/src/pages/public/ShopPage.tsx @@ -1,5 +1,5 @@ import { useState, useEffect } from 'react'; -import { Card, Row, Col, Button, Typography, Tag, Spin, Select, Space, App } from 'antd'; +import { Card, Row, Col, Button, Typography, Tag, Spin, Select, Space, Pagination, App } from 'antd'; import { ShoppingCartOutlined, PlayCircleOutlined } from '@ant-design/icons'; import { useNavigate } from 'react-router-dom'; import axios from 'axios'; @@ -15,6 +15,8 @@ export default function ShopPage() { const [products, setProducts] = useState([]); const [loading, setLoading] = useState(true); const [typeFilter, setTypeFilter] = useState(); + const [page, setPage] = useState(1); + const [total, setTotal] = useState(0); const { user, isAuthenticated } = useAuthStore(); const { message } = App.useApp(); const { settings: siteSettings } = useSettingsStore(); @@ -26,13 +28,17 @@ export default function ShopPage() { }, [siteSettings?.organizationName]); useEffect(() => { - const params: Record = {}; + setLoading(true); + const params: Record = { page, limit: 20 }; if (typeFilter) params.type = typeFilter; axios.get('/api/payments/products', { params }) - .then(({ data }) => setProducts(data)) + .then(({ data }) => { + setProducts(data.products); + setTotal(data.pagination.total); + }) .catch(() => message.error('Failed to load products')) .finally(() => setLoading(false)); - }, [typeFilter]); // eslint-disable-line react-hooks/exhaustive-deps + }, [typeFilter, page]); // eslint-disable-line react-hooks/exhaustive-deps const handlePurchase = async (e: React.MouseEvent, product: Product) => { e.stopPropagation(); // prevent card click navigation @@ -80,7 +86,7 @@ export default function ShopPage() { placeholder="Filter by type" allowClear value={typeFilter} - onChange={setTypeFilter} + onChange={(v) => { setTypeFilter(v); setPage(1); }} style={{ width: 200 }} options={[ { value: 'DIGITAL', label: 'Digital Products' }, @@ -202,6 +208,18 @@ export default function ShopPage() { })} )} + + {total > 20 && ( +
+ { setPage(p); window.scrollTo({ top: 0, behavior: 'smooth' }); }} + showSizeChanger={false} + /> +
+ )}
); } diff --git a/admin/src/types/api.ts b/admin/src/types/api.ts index 832b3785..639212fa 100644 --- a/admin/src/types/api.ts +++ b/admin/src/types/api.ts @@ -1304,6 +1304,9 @@ export interface PangolinStatus { orgId: string | null; siteId: string | null; newtConfigured: boolean; + siteIdValid: boolean | null; + resolvedSiteId: string | null; + siteIdMismatch: boolean; } export interface PangolinConfig { @@ -1329,6 +1332,24 @@ export interface PangolinSite { lastSeen?: string; online?: boolean; type?: string; + isCurrentSite?: boolean; +} + +export interface ConnectSiteResult { + success: boolean; + site: { + siteId: string; + name: string; + niceId: string; + online: boolean; + }; + envUpdate: { + success: boolean; + updated: string[]; + added: string[]; + error?: string; + }; + message: string; } export interface PangolinExitNode { diff --git a/api/Dockerfile b/api/Dockerfile index 52207f14..532243a2 100644 --- a/api/Dockerfile +++ b/api/Dockerfile @@ -25,6 +25,8 @@ RUN npm run build # Production stage FROM node:22-alpine AS production WORKDIR /app +# su-exec for dropping privileges after fixing mounted volume permissions +RUN apk add --no-cache su-exec # Copy compiled output and manifests COPY --from=build /app/dist ./dist COPY --from=build /app/package.json ./ @@ -37,8 +39,10 @@ COPY --from=build /app/tsconfig.json ./ RUN npm ci --omit=dev && npm install tsx && npx prisma generate COPY --from=build /app/docker-entrypoint.sh /usr/local/bin/ RUN chmod +x /usr/local/bin/docker-entrypoint.sh \ - && mkdir -p /app/uploads && chown -R node:node /app/uploads + && mkdir -p /app/uploads /app/logs /data/geoip \ + && chown -R node:node /app/uploads /app/logs /data/geoip -USER node +# Note: USER node is NOT set here — entrypoint runs as root to fix +# mounted volume permissions, then drops to node via su-exec ENTRYPOINT ["docker-entrypoint.sh"] CMD ["npm", "start"] diff --git a/api/prisma/migrations/20260404100000_disable_public_map_by_default/migration.sql b/api/prisma/migrations/20260404100000_disable_public_map_by_default/migration.sql new file mode 100644 index 00000000..e677b0d9 --- /dev/null +++ b/api/prisma/migrations/20260404100000_disable_public_map_by_default/migration.sql @@ -0,0 +1,3 @@ +-- Change default for public map toggles so fresh installs start with map disabled +ALTER TABLE "map_settings" ALTER COLUMN "publicMapEnabled" SET DEFAULT false; +ALTER TABLE "site_settings" ALTER COLUMN "enableMap" SET DEFAULT false; diff --git a/api/src/modules/gallery-ads/gallery-ads.service.ts b/api/src/modules/gallery-ads/gallery-ads.service.ts index d7ad34af..1ea21f8e 100644 --- a/api/src/modules/gallery-ads/gallery-ads.service.ts +++ b/api/src/modules/gallery-ads/gallery-ads.service.ts @@ -142,6 +142,7 @@ class GalleryAdsService { ], }, orderBy: { position: 'asc' }, + take: 100, // Safety cap: post-fetch filter may reduce further }); // Filter in application layer for complex logic diff --git a/api/src/modules/influence/campaigns/campaigns-public.routes.ts b/api/src/modules/influence/campaigns/campaigns-public.routes.ts index e54b41cb..41f417db 100644 --- a/api/src/modules/influence/campaigns/campaigns-public.routes.ts +++ b/api/src/modules/influence/campaigns/campaigns-public.routes.ts @@ -5,13 +5,15 @@ import { redis } from '../../../config/redis'; const router = Router(); -// GET /api/campaigns/public — list all active campaigns (public) +// GET /api/campaigns/public?page=1&limit=20 — list active campaigns (public, paginated) router.get( '/public', - async (_req: Request, res: Response, next: NextFunction) => { + async (req: Request, res: Response, next: NextFunction) => { try { - const campaigns = await campaignsService.findActiveCampaigns(); - res.json(campaigns); + const page = Math.max(parseInt(req.query.page as string) || 1, 1); + const limit = Math.min(parseInt(req.query.limit as string) || 20, 50); + const result = await campaignsService.findActiveCampaigns(page, limit); + res.json(result); } catch (err) { next(err); } diff --git a/api/src/modules/influence/campaigns/campaigns.service.ts b/api/src/modules/influence/campaigns/campaigns.service.ts index 530f9ba5..0ff5a143 100644 --- a/api/src/modules/influence/campaigns/campaigns.service.ts +++ b/api/src/modules/influence/campaigns/campaigns.service.ts @@ -256,15 +256,28 @@ export const campaignsService = { return campaign; }, - async findActiveCampaigns() { - return prisma.campaign.findMany({ - where: { status: 'ACTIVE' }, - select: publicCampaignSelect, - orderBy: [ - { highlightCampaign: 'desc' }, - { createdAt: 'desc' }, - ], - }); + async findActiveCampaigns(page: number = 1, limit: number = 20) { + const where = { status: 'ACTIVE' as const }; + const skip = (page - 1) * limit; + + const [campaigns, total] = await Promise.all([ + prisma.campaign.findMany({ + where, + select: publicCampaignSelect, + orderBy: [ + { highlightCampaign: 'desc' }, + { createdAt: 'desc' }, + ], + skip, + take: limit, + }), + prisma.campaign.count({ where }), + ]); + + return { + campaigns, + pagination: { page, limit, total, totalPages: Math.ceil(total / limit) }, + }; }, async findBySlugPublic(slug: string) { diff --git a/api/src/modules/influence/petitions/petitions-public.routes.ts b/api/src/modules/influence/petitions/petitions-public.routes.ts index 646ef9d0..728a78f9 100644 --- a/api/src/modules/influence/petitions/petitions-public.routes.ts +++ b/api/src/modules/influence/petitions/petitions-public.routes.ts @@ -6,13 +6,15 @@ import { petitionSignRateLimit } from '../../../middleware/rate-limit'; const router = Router(); -// GET /api/petitions/public — list active petitions +// GET /api/petitions/public?page=1&limit=20 — list active petitions (paginated) router.get( '/public', - async (_req: Request, res: Response, next: NextFunction) => { + async (req: Request, res: Response, next: NextFunction) => { try { - const petitions = await petitionsService.findActivePetitions(); - res.json(petitions); + const page = Math.max(parseInt(req.query.page as string) || 1, 1); + const limit = Math.min(parseInt(req.query.limit as string) || 20, 50); + const result = await petitionsService.findActivePetitions(page, limit); + res.json(result); } catch (err) { next(err); } } ); diff --git a/api/src/modules/influence/petitions/petitions.service.ts b/api/src/modules/influence/petitions/petitions.service.ts index 63c249b8..98bfb2ee 100644 --- a/api/src/modules/influence/petitions/petitions.service.ts +++ b/api/src/modules/influence/petitions/petitions.service.ts @@ -251,21 +251,34 @@ export const petitionsService = { // ─── Public Routes ─────────────────────────────────────────────────── - async findActivePetitions() { - return prisma.petition.findMany({ - where: { - status: 'ACTIVE', - OR: [ - { isUserGenerated: false }, - { isUserGenerated: true, moderationStatus: 'APPROVED' }, - ], - }, - select: publicPetitionSelect, - orderBy: [ - { highlightPetition: 'desc' }, - { createdAt: 'desc' }, + async findActivePetitions(page: number = 1, limit: number = 20) { + const where = { + status: 'ACTIVE' as const, + OR: [ + { isUserGenerated: false }, + { isUserGenerated: true, moderationStatus: 'APPROVED' as const }, ], - }); + }; + const skip = (page - 1) * limit; + + const [petitions, total] = await Promise.all([ + prisma.petition.findMany({ + where, + select: publicPetitionSelect, + orderBy: [ + { highlightPetition: 'desc' }, + { createdAt: 'desc' }, + ], + skip, + take: limit, + }), + prisma.petition.count({ where }), + ]); + + return { + petitions, + pagination: { page, limit, total, totalPages: Math.ceil(total / limit) }, + }; }, async findBySlugPublic(slug: string) { diff --git a/api/src/modules/map/cuts/cuts.routes.ts b/api/src/modules/map/cuts/cuts.routes.ts index 23240a60..5c05280e 100644 --- a/api/src/modules/map/cuts/cuts.routes.ts +++ b/api/src/modules/map/cuts/cuts.routes.ts @@ -175,12 +175,13 @@ adminRouter.get( // --- Public Router --- const publicRouter = Router(); -// GET /api/map/cuts/public — all public cuts for map display +// GET /api/map/cuts/public?limit=50 — public cuts for map display publicRouter.get( '/public', - async (_req: Request, res: Response, next: NextFunction) => { + async (req: Request, res: Response, next: NextFunction) => { try { - const cuts = await cutsService.getPublicCuts(); + const limit = Math.min(parseInt(req.query.limit as string) || 50, 100); + const cuts = await cutsService.getPublicCuts(limit); res.json(cuts); } catch (err) { next(err); diff --git a/api/src/modules/map/cuts/cuts.service.ts b/api/src/modules/map/cuts/cuts.service.ts index 35b2fd87..7cdd5bb0 100644 --- a/api/src/modules/map/cuts/cuts.service.ts +++ b/api/src/modules/map/cuts/cuts.service.ts @@ -125,7 +125,7 @@ export const cutsService = { await prisma.cut.delete({ where: { id } }); }, - async getPublicCuts() { + async getPublicCuts(limit: number = 50) { const cuts = await prisma.cut.findMany({ where: { isPublic: true }, select: { @@ -139,6 +139,7 @@ export const cutsService = { bounds: true, }, orderBy: { name: 'asc' }, + take: limit, }); return cuts; }, diff --git a/api/src/modules/map/shifts/shifts.routes.ts b/api/src/modules/map/shifts/shifts.routes.ts index 2ab4e8b8..1a6547b2 100644 --- a/api/src/modules/map/shifts/shifts.routes.ts +++ b/api/src/modules/map/shifts/shifts.routes.ts @@ -260,13 +260,15 @@ volunteerRouter.delete( // --- Public Router --- const publicRouter = Router(); -// GET /api/map/shifts/public — list upcoming public shifts +// GET /api/map/shifts/public?page=1&limit=20 — list upcoming public shifts (paginated) publicRouter.get( '/public', - async (_req: Request, res: Response, next: NextFunction) => { + async (req: Request, res: Response, next: NextFunction) => { try { - const shifts = await shiftsService.getPublicShifts(); - res.json(shifts); + const page = Math.max(parseInt(req.query.page as string) || 1, 1); + const limit = Math.min(parseInt(req.query.limit as string) || 20, 50); + const result = await shiftsService.getPublicShifts(page, limit); + res.json(result); } catch (err) { next(err); } diff --git a/api/src/modules/map/shifts/shifts.service.ts b/api/src/modules/map/shifts/shifts.service.ts index ff93e91e..54cefaaa 100644 --- a/api/src/modules/map/shifts/shifts.service.ts +++ b/api/src/modules/map/shifts/shifts.service.ts @@ -1121,30 +1121,41 @@ export const shiftsService = { return signups; }, - async getPublicShifts() { - const shifts = await prisma.shift.findMany({ - where: { - isPublic: true, - status: { not: ShiftStatus.CANCELLED }, - date: { gte: new Date(new Date().toISOString().split('T')[0]) }, - }, - select: { - id: true, - title: true, - description: true, - date: true, - startTime: true, - endTime: true, - location: true, - maxVolunteers: true, - currentVolunteers: true, - status: true, - meeting: { select: { id: true, slug: true, isActive: true } }, - }, - orderBy: [{ date: 'asc' }, { startTime: 'asc' }], - }); + async getPublicShifts(page: number = 1, limit: number = 20) { + const where = { + isPublic: true, + status: { not: ShiftStatus.CANCELLED }, + date: { gte: new Date(new Date().toISOString().split('T')[0]) }, + }; + const skip = (page - 1) * limit; - return shifts; + const [shifts, total] = await Promise.all([ + prisma.shift.findMany({ + where, + select: { + id: true, + title: true, + description: true, + date: true, + startTime: true, + endTime: true, + location: true, + maxVolunteers: true, + currentVolunteers: true, + status: true, + meeting: { select: { id: true, slug: true, isActive: true } }, + }, + orderBy: [{ date: 'asc' }, { startTime: 'asc' }], + skip, + take: limit, + }), + prisma.shift.count({ where: { isPublic: true, status: { not: ShiftStatus.CANCELLED }, date: { gte: new Date(new Date().toISOString().split('T')[0]) } } }), + ]); + + return { + shifts, + pagination: { page, limit, total, totalPages: Math.ceil(total / limit) }, + }; }, async emailShiftDetails(shiftId: string) { diff --git a/api/src/modules/pages/pages-public.routes.ts b/api/src/modules/pages/pages-public.routes.ts index 8a02feac..e78cb4e4 100644 --- a/api/src/modules/pages/pages-public.routes.ts +++ b/api/src/modules/pages/pages-public.routes.ts @@ -4,23 +4,37 @@ import { pagesService } from './pages.service'; const router = Router(); -// GET /api/pages/listed — get published + listed pages for public index (no auth) +// GET /api/pages/listed?page=1&limit=20 — get published + listed pages for public index (no auth, paginated) router.get( '/listed', - async (_req: Request, res: Response, next: NextFunction) => { + async (req: Request, res: Response, next: NextFunction) => { try { - const pages = await prisma.landingPage.findMany({ - where: { published: true, listed: true }, - select: { - slug: true, - title: true, - description: true, - seoImage: true, - updatedAt: true, - }, - orderBy: { updatedAt: 'desc' }, + const page = Math.max(parseInt(req.query.page as string) || 1, 1); + const limit = Math.min(parseInt(req.query.limit as string) || 20, 50); + const where = { published: true, listed: true }; + const skip = (page - 1) * limit; + + const [pages, total] = await Promise.all([ + prisma.landingPage.findMany({ + where, + select: { + slug: true, + title: true, + description: true, + seoImage: true, + updatedAt: true, + }, + orderBy: { updatedAt: 'desc' }, + skip, + take: limit, + }), + prisma.landingPage.count({ where }), + ]); + + res.json({ + pages, + pagination: { page, limit, total, totalPages: Math.ceil(total / limit) }, }); - res.json(pages); } catch (err) { next(err); } diff --git a/api/src/modules/pangolin/pangolin.routes.ts b/api/src/modules/pangolin/pangolin.routes.ts index dd1cf530..5a02ddd1 100644 --- a/api/src/modules/pangolin/pangolin.routes.ts +++ b/api/src/modules/pangolin/pangolin.routes.ts @@ -172,14 +172,44 @@ const router = Router(); router.use(authenticate); router.use(requireRole('SUPER_ADMIN')); -// GET /api/pangolin/status — Health + connection info +// GET /api/pangolin/status — Health + connection info + site ID validation router.get('/status', async (_req: Request, res: Response) => { try { const configured = pangolinClient.configured; let healthy = false; + let siteIdValid: boolean | null = null; + let resolvedSiteId: string | null = null; + let siteIdMismatch = false; if (configured) { healthy = await pangolinClient.healthCheck(); + + // Validate site ID by checking if it exists in the org + // Pangolin returns siteId as a number; env stores it as a string — compare with String() + if (healthy) { + const envSiteId = env.PANGOLIN_SITE_ID; + if (envSiteId) { + try { + const sites = await pangolinClient.listSites(); + + // Match env siteId against org sites (coerce types for comparison) + const match = sites.find(s => + String(s.siteId) === envSiteId || s.niceId === envSiteId + ); + + if (match) { + siteIdValid = true; + resolvedSiteId = String(match.siteId); + } else { + siteIdValid = false; + siteIdMismatch = true; + logger.warn(`PANGOLIN_SITE_ID "${envSiteId}" not found in org (${sites.length} sites available)`); + } + } catch (err) { + logger.warn('Could not validate site ID (non-critical):', err instanceof Error ? err.message : err); + } + } + } } res.json({ @@ -189,6 +219,9 @@ router.get('/status', async (_req: Request, res: Response) => { orgId: env.PANGOLIN_ORG_ID || null, siteId: env.PANGOLIN_SITE_ID || null, newtConfigured: !!(env.PANGOLIN_NEWT_ID && env.PANGOLIN_NEWT_SECRET), + siteIdValid, + resolvedSiteId, + siteIdMismatch, }); } catch (err) { logger.error('Pangolin status check failed:', err); @@ -265,17 +298,95 @@ router.post('/newt-restart', async (_req: Request, res: Response) => { } }); -// GET /api/pangolin/sites — List sites +// GET /api/pangolin/sites — List sites (with newtId matching for site picker) router.get('/sites', async (_req: Request, res: Response) => { try { const sites = await pangolinClient.listSites(); - res.json({ sites }); + const currentNewtId = env.PANGOLIN_NEWT_ID || null; + const currentSiteId = env.PANGOLIN_SITE_ID || null; + + // Annotate each site with whether it matches current env config + // Pangolin returns siteId as a number; env stores it as a string — compare with String() + const annotatedSites = sites.map(s => ({ + ...s, + isCurrentSite: currentSiteId + ? (String(s.siteId) === currentSiteId || s.niceId === currentSiteId) + : false, + })); + + res.json({ sites: annotatedSites, currentNewtId, currentSiteId }); } catch (err) { const msg = err instanceof Error ? err.message : 'Unknown error'; res.status(500).json({ error: { message: msg, code: 'PANGOLIN_ERROR' } }); } }); +// POST /api/pangolin/connect-site — Connect to an existing site (write site ID + newt creds to .env) +const connectSiteSchema = z.object({ + siteId: z.union([z.string().min(1).max(200), z.number().int().positive()]).transform(String), +}); + +router.post('/connect-site', pangolinSetupLimiter, async (req: Request, res: Response) => { + try { + if (!pangolinClient.configured) { + res.status(400).json({ + error: { message: 'Pangolin not configured', code: 'NOT_CONFIGURED' }, + }); + return; + } + + const { siteId } = connectSiteSchema.parse(req.body); + + // Verify the site exists in the org + const sites = await pangolinClient.listSites(); + const site = sites.find(s => String(s.siteId) === siteId || s.niceId === siteId); + + if (!site) { + res.status(404).json({ + error: { message: `Site "${siteId}" not found in organization`, code: 'SITE_NOT_FOUND' }, + }); + return; + } + + // Build env updates — always write the site ID (coerce to string for .env) + const envUpdates: Record = { + PANGOLIN_SITE_ID: String(site.siteId), + }; + + // If the site has a Pangolin endpoint, write that too + if (env.PANGOLIN_API_URL) { + // Derive the endpoint from the API URL (strip /v1 path) + const endpoint = env.PANGOLIN_API_URL.replace(/\/v1\/?$/, ''); + envUpdates.PANGOLIN_ENDPOINT = endpoint; + } + + // Write to .env + const envResult = updateEnvFile(envUpdates); + + logger.info(`Connected to Pangolin site: ${site.siteId} (name: ${site.name})`); + + res.json({ + success: true, + site: { + siteId: site.siteId, + name: site.name, + niceId: site.niceId, + online: site.online, + }, + envUpdate: envResult, + message: `Connected to site "${site.name}". Restart the API container to apply the new PANGOLIN_SITE_ID.`, + }); + } catch (err) { + if (err instanceof z.ZodError) { + res.status(400).json({ error: { message: 'Invalid request body', code: 'VALIDATION_ERROR' } }); + return; + } + const msg = err instanceof Error ? err.message : 'Unknown error'; + logger.error('Connect site failed:', err); + res.status(500).json({ error: { message: msg, code: 'PANGOLIN_ERROR' } }); + } +}); + // GET /api/pangolin/exit-nodes — List available exit nodes router.get('/exit-nodes', async (_req: Request, res: Response) => { try { diff --git a/api/src/modules/payments/donation-pages-public.routes.ts b/api/src/modules/payments/donation-pages-public.routes.ts index 748331dd..07b562b3 100644 --- a/api/src/modules/payments/donation-pages-public.routes.ts +++ b/api/src/modules/payments/donation-pages-public.routes.ts @@ -8,10 +8,11 @@ import { donationPageCheckoutSchema } from './donation-pages.schemas'; const router = Router(); -// GET /api/donation-pages — list active pages (with stats) -router.get('/', async (_req: Request, res: Response, next: NextFunction) => { +// GET /api/donation-pages?limit=20 — list active pages (with stats) +router.get('/', async (req: Request, res: Response, next: NextFunction) => { try { - const pages = await donationPagesService.findActivePages(); + const limit = Math.min(parseInt(req.query.limit as string) || 20, 50); + const pages = await donationPagesService.findActivePages(limit); res.json(pages); } catch (err) { next(err); diff --git a/api/src/modules/payments/donation-pages.service.ts b/api/src/modules/payments/donation-pages.service.ts index 9031f404..aa556993 100644 --- a/api/src/modules/payments/donation-pages.service.ts +++ b/api/src/modules/payments/donation-pages.service.ts @@ -193,13 +193,14 @@ export const donationPagesService = { }, /** Public: list active donation pages with stats */ - async findActivePages() { + async findActivePages(limit: number = 20) { const pages = await prisma.donationPage.findMany({ where: { status: 'ACTIVE' }, orderBy: [ { highlightPage: 'desc' }, { createdAt: 'desc' }, ], + take: limit, }); return Promise.all( diff --git a/api/src/modules/payments/payments-public.routes.ts b/api/src/modules/payments/payments-public.routes.ts index 08ea79de..4cf4fd96 100644 --- a/api/src/modules/payments/payments-public.routes.ts +++ b/api/src/modules/payments/payments-public.routes.ts @@ -36,10 +36,11 @@ router.get('/config', async (_req: Request, res: Response, next: NextFunction) = } }); -// GET /api/payments/plans — list active subscription plans -router.get('/plans', async (_req: Request, res: Response, next: NextFunction) => { +// GET /api/payments/plans?limit=50 — list active subscription plans +router.get('/plans', async (req: Request, res: Response, next: NextFunction) => { try { - const plans = await plansService.listActivePlans(); + const limit = Math.min(parseInt(req.query.limit as string) || 50, 100); + const plans = await plansService.listActivePlans(limit); res.json(plans); } catch (err) { next(err); @@ -57,12 +58,14 @@ router.get('/plans/:slug', async (req: Request, res: Response, next: NextFunctio } }); -// GET /api/payments/products — list active products +// GET /api/payments/products?page=1&limit=20 — list active products (paginated) router.get('/products', async (req: Request, res: Response, next: NextFunction) => { try { const type = req.query.type as string | undefined; - const products = await productsService.listActive(type); - res.json(products); + const page = Math.max(parseInt(req.query.page as string) || 1, 1); + const limit = Math.min(parseInt(req.query.limit as string) || 20, 50); + const result = await productsService.listActive(type, page, limit); + res.json(result); } catch (err) { next(err); } diff --git a/api/src/modules/payments/plans.service.ts b/api/src/modules/payments/plans.service.ts index 71517cdd..e6f9fa07 100644 --- a/api/src/modules/payments/plans.service.ts +++ b/api/src/modules/payments/plans.service.ts @@ -152,13 +152,14 @@ export const plansService = { }, /** Public: list active plans for pricing page */ - async listActivePlans() { + async listActivePlans(limit: number = 50) { return prisma.subscriptionPlan.findMany({ where: { isActive: true }, orderBy: [ { highlightPlan: 'desc' }, { displayOrder: 'asc' }, ], + take: limit, }); }, diff --git a/api/src/modules/payments/products.service.ts b/api/src/modules/payments/products.service.ts index 9e10be15..195381a7 100644 --- a/api/src/modules/payments/products.service.ts +++ b/api/src/modules/payments/products.service.ts @@ -88,15 +88,26 @@ function productAdDefaults(product: { title: string; description: string | null; } export const productsService = { - /** List active products (public) */ - async listActive(type?: string) { + /** List active products (public, paginated) */ + async listActive(type?: string, page: number = 1, limit: number = 20) { const where: Prisma.ProductWhereInput = { isActive: true }; if (type) where.type = type as Prisma.EnumProductTypeFilter['equals']; - const products = await prisma.product.findMany({ - where, - orderBy: { createdAt: 'desc' }, - }); - return products.map(resolveMediaUrls); + const skip = (page - 1) * limit; + + const [products, total] = await Promise.all([ + prisma.product.findMany({ + where, + orderBy: { createdAt: 'desc' }, + skip, + take: limit, + }), + prisma.product.count({ where }), + ]); + + return { + products: products.map(resolveMediaUrls), + pagination: { page, limit, total, totalPages: Math.ceil(total / limit) }, + }; }, /** List all products (admin) */ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/404.png b/mkdocs/.cache/plugin/social/assets/images/social/404.png index 34707f29..469c8e22 100644 Binary files a/mkdocs/.cache/plugin/social/assets/images/social/404.png and b/mkdocs/.cache/plugin/social/assets/images/social/404.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/blog/2026/03/22/introducing-changemaker-lite-v2.png b/mkdocs/.cache/plugin/social/assets/images/social/blog/2026/03/22/introducing-changemaker-lite-v2.png index 9b02bd7e..4d013272 100644 Binary files a/mkdocs/.cache/plugin/social/assets/images/social/blog/2026/03/22/introducing-changemaker-lite-v2.png and b/mkdocs/.cache/plugin/social/assets/images/social/blog/2026/03/22/introducing-changemaker-lite-v2.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/blog/2026/03/27/test-blog-post---version-7.png b/mkdocs/.cache/plugin/social/assets/images/social/blog/2026/03/27/test-blog-post---version-7.png index 06d3f3f6..15f6b59a 100644 Binary files a/mkdocs/.cache/plugin/social/assets/images/social/blog/2026/03/27/test-blog-post---version-7.png and b/mkdocs/.cache/plugin/social/assets/images/social/blog/2026/03/27/test-blog-post---version-7.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/blog/archive/2026.png b/mkdocs/.cache/plugin/social/assets/images/social/blog/archive/2026.png index 7f6083f8..9dcf64bd 100644 Binary files a/mkdocs/.cache/plugin/social/assets/images/social/blog/archive/2026.png and b/mkdocs/.cache/plugin/social/assets/images/social/blog/archive/2026.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/blog/category/announcements.png b/mkdocs/.cache/plugin/social/assets/images/social/blog/category/announcements.png index 90a75672..8e52e3cc 100644 Binary files a/mkdocs/.cache/plugin/social/assets/images/social/blog/category/announcements.png and b/mkdocs/.cache/plugin/social/assets/images/social/blog/category/announcements.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/blog/category/platform.png b/mkdocs/.cache/plugin/social/assets/images/social/blog/category/platform.png index 259966fc..3b8a320b 100644 Binary files a/mkdocs/.cache/plugin/social/assets/images/social/blog/category/platform.png and b/mkdocs/.cache/plugin/social/assets/images/social/blog/category/platform.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/blog/category/testing.png b/mkdocs/.cache/plugin/social/assets/images/social/blog/category/testing.png index c6d22b02..9d6d7294 100644 Binary files a/mkdocs/.cache/plugin/social/assets/images/social/blog/category/testing.png and b/mkdocs/.cache/plugin/social/assets/images/social/blog/category/testing.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/blog/index.png b/mkdocs/.cache/plugin/social/assets/images/social/blog/index.png index 62ec13c2..b49db94b 100644 Binary files a/mkdocs/.cache/plugin/social/assets/images/social/blog/index.png and b/mkdocs/.cache/plugin/social/assets/images/social/blog/index.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/comments/callback.png b/mkdocs/.cache/plugin/social/assets/images/social/comments/callback.png index 0b5018b1..8e47c940 100644 Binary files a/mkdocs/.cache/plugin/social/assets/images/social/comments/callback.png and b/mkdocs/.cache/plugin/social/assets/images/social/comments/callback.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/docs/admin/advocacy/campaigns.png b/mkdocs/.cache/plugin/social/assets/images/social/docs/admin/advocacy/campaigns.png index 7be13e25..b7606bb1 100644 Binary files a/mkdocs/.cache/plugin/social/assets/images/social/docs/admin/advocacy/campaigns.png and b/mkdocs/.cache/plugin/social/assets/images/social/docs/admin/advocacy/campaigns.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/docs/admin/advocacy/email-queue.png b/mkdocs/.cache/plugin/social/assets/images/social/docs/admin/advocacy/email-queue.png index 5f5291d5..faf2f96f 100644 Binary files a/mkdocs/.cache/plugin/social/assets/images/social/docs/admin/advocacy/email-queue.png and b/mkdocs/.cache/plugin/social/assets/images/social/docs/admin/advocacy/email-queue.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/docs/admin/advocacy/index.png b/mkdocs/.cache/plugin/social/assets/images/social/docs/admin/advocacy/index.png index fd870383..a5ec466a 100644 Binary files a/mkdocs/.cache/plugin/social/assets/images/social/docs/admin/advocacy/index.png and b/mkdocs/.cache/plugin/social/assets/images/social/docs/admin/advocacy/index.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/docs/admin/advocacy/representatives.png b/mkdocs/.cache/plugin/social/assets/images/social/docs/admin/advocacy/representatives.png index d398747a..3f0911d5 100644 Binary files a/mkdocs/.cache/plugin/social/assets/images/social/docs/admin/advocacy/representatives.png and b/mkdocs/.cache/plugin/social/assets/images/social/docs/admin/advocacy/representatives.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/docs/admin/advocacy/responses.png b/mkdocs/.cache/plugin/social/assets/images/social/docs/admin/advocacy/responses.png index 5a4d0947..af03bc31 100644 Binary files a/mkdocs/.cache/plugin/social/assets/images/social/docs/admin/advocacy/responses.png and b/mkdocs/.cache/plugin/social/assets/images/social/docs/admin/advocacy/responses.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/docs/admin/broadcast/email-templates.png b/mkdocs/.cache/plugin/social/assets/images/social/docs/admin/broadcast/email-templates.png index ffcb12fd..63f3a826 100644 Binary files a/mkdocs/.cache/plugin/social/assets/images/social/docs/admin/broadcast/email-templates.png and b/mkdocs/.cache/plugin/social/assets/images/social/docs/admin/broadcast/email-templates.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/docs/admin/broadcast/index.png b/mkdocs/.cache/plugin/social/assets/images/social/docs/admin/broadcast/index.png index 4437fac7..8f582f8b 100644 Binary files a/mkdocs/.cache/plugin/social/assets/images/social/docs/admin/broadcast/index.png and b/mkdocs/.cache/plugin/social/assets/images/social/docs/admin/broadcast/index.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/docs/admin/broadcast/newsletter.png b/mkdocs/.cache/plugin/social/assets/images/social/docs/admin/broadcast/newsletter.png index 19e41c32..e73fa1c1 100644 Binary files a/mkdocs/.cache/plugin/social/assets/images/social/docs/admin/broadcast/newsletter.png and b/mkdocs/.cache/plugin/social/assets/images/social/docs/admin/broadcast/newsletter.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/docs/admin/broadcast/sms.png b/mkdocs/.cache/plugin/social/assets/images/social/docs/admin/broadcast/sms.png index 6c33b625..9fec46db 100644 Binary files a/mkdocs/.cache/plugin/social/assets/images/social/docs/admin/broadcast/sms.png and b/mkdocs/.cache/plugin/social/assets/images/social/docs/admin/broadcast/sms.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/docs/admin/dashboard.png b/mkdocs/.cache/plugin/social/assets/images/social/docs/admin/dashboard.png index cfcd4317..e46efc88 100644 Binary files a/mkdocs/.cache/plugin/social/assets/images/social/docs/admin/dashboard.png and b/mkdocs/.cache/plugin/social/assets/images/social/docs/admin/dashboard.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/docs/admin/index.png b/mkdocs/.cache/plugin/social/assets/images/social/docs/admin/index.png index d734524d..378f95f0 100644 Binary files a/mkdocs/.cache/plugin/social/assets/images/social/docs/admin/index.png and b/mkdocs/.cache/plugin/social/assets/images/social/docs/admin/index.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/docs/admin/map/areas.png b/mkdocs/.cache/plugin/social/assets/images/social/docs/admin/map/areas.png index d7bf4412..48a209f2 100644 Binary files a/mkdocs/.cache/plugin/social/assets/images/social/docs/admin/map/areas.png and b/mkdocs/.cache/plugin/social/assets/images/social/docs/admin/map/areas.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/docs/admin/map/canvassing.png b/mkdocs/.cache/plugin/social/assets/images/social/docs/admin/map/canvassing.png index 85df7f2a..2532e986 100644 Binary files a/mkdocs/.cache/plugin/social/assets/images/social/docs/admin/map/canvassing.png and b/mkdocs/.cache/plugin/social/assets/images/social/docs/admin/map/canvassing.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/docs/admin/map/data-quality.png b/mkdocs/.cache/plugin/social/assets/images/social/docs/admin/map/data-quality.png index 76da770f..63ab0557 100644 Binary files a/mkdocs/.cache/plugin/social/assets/images/social/docs/admin/map/data-quality.png and b/mkdocs/.cache/plugin/social/assets/images/social/docs/admin/map/data-quality.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/docs/admin/map/index.png b/mkdocs/.cache/plugin/social/assets/images/social/docs/admin/map/index.png index b683a2ed..6bb05317 100644 Binary files a/mkdocs/.cache/plugin/social/assets/images/social/docs/admin/map/index.png and b/mkdocs/.cache/plugin/social/assets/images/social/docs/admin/map/index.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/docs/admin/map/locations.png b/mkdocs/.cache/plugin/social/assets/images/social/docs/admin/map/locations.png index a04ec2a7..9b51c676 100644 Binary files a/mkdocs/.cache/plugin/social/assets/images/social/docs/admin/map/locations.png and b/mkdocs/.cache/plugin/social/assets/images/social/docs/admin/map/locations.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/docs/admin/map/settings.png b/mkdocs/.cache/plugin/social/assets/images/social/docs/admin/map/settings.png index 13d1e778..9d5619eb 100644 Binary files a/mkdocs/.cache/plugin/social/assets/images/social/docs/admin/map/settings.png and b/mkdocs/.cache/plugin/social/assets/images/social/docs/admin/map/settings.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/docs/admin/map/shifts.png b/mkdocs/.cache/plugin/social/assets/images/social/docs/admin/map/shifts.png index 6841beb8..3c8d7969 100644 Binary files a/mkdocs/.cache/plugin/social/assets/images/social/docs/admin/map/shifts.png and b/mkdocs/.cache/plugin/social/assets/images/social/docs/admin/map/shifts.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/docs/admin/media/ads.png b/mkdocs/.cache/plugin/social/assets/images/social/docs/admin/media/ads.png index e3efcb9a..b4514cb8 100644 Binary files a/mkdocs/.cache/plugin/social/assets/images/social/docs/admin/media/ads.png and b/mkdocs/.cache/plugin/social/assets/images/social/docs/admin/media/ads.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/docs/admin/media/analytics.png b/mkdocs/.cache/plugin/social/assets/images/social/docs/admin/media/analytics.png index 4e1e886d..9024bf0e 100644 Binary files a/mkdocs/.cache/plugin/social/assets/images/social/docs/admin/media/analytics.png and b/mkdocs/.cache/plugin/social/assets/images/social/docs/admin/media/analytics.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/docs/admin/media/curated.png b/mkdocs/.cache/plugin/social/assets/images/social/docs/admin/media/curated.png index 279e5126..4faa3e36 100644 Binary files a/mkdocs/.cache/plugin/social/assets/images/social/docs/admin/media/curated.png and b/mkdocs/.cache/plugin/social/assets/images/social/docs/admin/media/curated.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/docs/admin/media/index.png b/mkdocs/.cache/plugin/social/assets/images/social/docs/admin/media/index.png index 98f5cff6..fc8102e5 100644 Binary files a/mkdocs/.cache/plugin/social/assets/images/social/docs/admin/media/index.png and b/mkdocs/.cache/plugin/social/assets/images/social/docs/admin/media/index.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/docs/admin/media/library.png b/mkdocs/.cache/plugin/social/assets/images/social/docs/admin/media/library.png index a5fa2140..c1643405 100644 Binary files a/mkdocs/.cache/plugin/social/assets/images/social/docs/admin/media/library.png and b/mkdocs/.cache/plugin/social/assets/images/social/docs/admin/media/library.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/docs/admin/media/moderation.png b/mkdocs/.cache/plugin/social/assets/images/social/docs/admin/media/moderation.png index ef2938ff..572d83ac 100644 Binary files a/mkdocs/.cache/plugin/social/assets/images/social/docs/admin/media/moderation.png and b/mkdocs/.cache/plugin/social/assets/images/social/docs/admin/media/moderation.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/docs/admin/payments/donations.png b/mkdocs/.cache/plugin/social/assets/images/social/docs/admin/payments/donations.png index b46a50b5..c6a205d8 100644 Binary files a/mkdocs/.cache/plugin/social/assets/images/social/docs/admin/payments/donations.png and b/mkdocs/.cache/plugin/social/assets/images/social/docs/admin/payments/donations.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/docs/admin/payments/index.png b/mkdocs/.cache/plugin/social/assets/images/social/docs/admin/payments/index.png index a5eccecf..4bc52f6b 100644 Binary files a/mkdocs/.cache/plugin/social/assets/images/social/docs/admin/payments/index.png and b/mkdocs/.cache/plugin/social/assets/images/social/docs/admin/payments/index.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/docs/admin/payments/plans.png b/mkdocs/.cache/plugin/social/assets/images/social/docs/admin/payments/plans.png index 01c1d47c..3804f59a 100644 Binary files a/mkdocs/.cache/plugin/social/assets/images/social/docs/admin/payments/plans.png and b/mkdocs/.cache/plugin/social/assets/images/social/docs/admin/payments/plans.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/docs/admin/payments/products.png b/mkdocs/.cache/plugin/social/assets/images/social/docs/admin/payments/products.png index 5013518d..f2362a5f 100644 Binary files a/mkdocs/.cache/plugin/social/assets/images/social/docs/admin/payments/products.png and b/mkdocs/.cache/plugin/social/assets/images/social/docs/admin/payments/products.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/docs/admin/payments/settings.png b/mkdocs/.cache/plugin/social/assets/images/social/docs/admin/payments/settings.png index f4fb56ed..410c40d2 100644 Binary files a/mkdocs/.cache/plugin/social/assets/images/social/docs/admin/payments/settings.png and b/mkdocs/.cache/plugin/social/assets/images/social/docs/admin/payments/settings.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/docs/admin/people-access.png b/mkdocs/.cache/plugin/social/assets/images/social/docs/admin/people-access.png index 8649ba11..1e2577b6 100644 Binary files a/mkdocs/.cache/plugin/social/assets/images/social/docs/admin/people-access.png and b/mkdocs/.cache/plugin/social/assets/images/social/docs/admin/people-access.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/docs/admin/services/crowdsec.png b/mkdocs/.cache/plugin/social/assets/images/social/docs/admin/services/crowdsec.png index a30d1768..0aa481bf 100644 Binary files a/mkdocs/.cache/plugin/social/assets/images/social/docs/admin/services/crowdsec.png and b/mkdocs/.cache/plugin/social/assets/images/social/docs/admin/services/crowdsec.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/docs/admin/services/index.png b/mkdocs/.cache/plugin/social/assets/images/social/docs/admin/services/index.png index ce42fd8c..9b97c86b 100644 Binary files a/mkdocs/.cache/plugin/social/assets/images/social/docs/admin/services/index.png and b/mkdocs/.cache/plugin/social/assets/images/social/docs/admin/services/index.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/docs/admin/services/integrations.png b/mkdocs/.cache/plugin/social/assets/images/social/docs/admin/services/integrations.png index 73022f48..6052f206 100644 Binary files a/mkdocs/.cache/plugin/social/assets/images/social/docs/admin/services/integrations.png and b/mkdocs/.cache/plugin/social/assets/images/social/docs/admin/services/integrations.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/docs/admin/services/monitoring.png b/mkdocs/.cache/plugin/social/assets/images/social/docs/admin/services/monitoring.png index cfd8b28f..71edbc15 100644 Binary files a/mkdocs/.cache/plugin/social/assets/images/social/docs/admin/services/monitoring.png and b/mkdocs/.cache/plugin/social/assets/images/social/docs/admin/services/monitoring.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/docs/admin/services/tunnel.png b/mkdocs/.cache/plugin/social/assets/images/social/docs/admin/services/tunnel.png index 2fb9cbd2..67fe8f2f 100644 Binary files a/mkdocs/.cache/plugin/social/assets/images/social/docs/admin/services/tunnel.png and b/mkdocs/.cache/plugin/social/assets/images/social/docs/admin/services/tunnel.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/docs/admin/services/user-provisioning.png b/mkdocs/.cache/plugin/social/assets/images/social/docs/admin/services/user-provisioning.png index 7b5f2bd4..e0ad00b5 100644 Binary files a/mkdocs/.cache/plugin/social/assets/images/social/docs/admin/services/user-provisioning.png and b/mkdocs/.cache/plugin/social/assets/images/social/docs/admin/services/user-provisioning.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/docs/admin/settings.png b/mkdocs/.cache/plugin/social/assets/images/social/docs/admin/settings.png index 1fd7695a..d3c5046c 100644 Binary files a/mkdocs/.cache/plugin/social/assets/images/social/docs/admin/settings.png and b/mkdocs/.cache/plugin/social/assets/images/social/docs/admin/settings.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/docs/admin/web/documentation.png b/mkdocs/.cache/plugin/social/assets/images/social/docs/admin/web/documentation.png index 5c537647..bb0bd024 100644 Binary files a/mkdocs/.cache/plugin/social/assets/images/social/docs/admin/web/documentation.png and b/mkdocs/.cache/plugin/social/assets/images/social/docs/admin/web/documentation.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/docs/admin/web/homepage.png b/mkdocs/.cache/plugin/social/assets/images/social/docs/admin/web/homepage.png index d87b981e..0829b498 100644 Binary files a/mkdocs/.cache/plugin/social/assets/images/social/docs/admin/web/homepage.png and b/mkdocs/.cache/plugin/social/assets/images/social/docs/admin/web/homepage.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/docs/admin/web/index.png b/mkdocs/.cache/plugin/social/assets/images/social/docs/admin/web/index.png index 0800ba75..21f3fc13 100644 Binary files a/mkdocs/.cache/plugin/social/assets/images/social/docs/admin/web/index.png and b/mkdocs/.cache/plugin/social/assets/images/social/docs/admin/web/index.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/docs/admin/web/landing-pages.png b/mkdocs/.cache/plugin/social/assets/images/social/docs/admin/web/landing-pages.png index 4c4c661a..c3b88017 100644 Binary files a/mkdocs/.cache/plugin/social/assets/images/social/docs/admin/web/landing-pages.png and b/mkdocs/.cache/plugin/social/assets/images/social/docs/admin/web/landing-pages.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/docs/admin/web/navigation.png b/mkdocs/.cache/plugin/social/assets/images/social/docs/admin/web/navigation.png index 5a1885b2..e96eb8b6 100644 Binary files a/mkdocs/.cache/plugin/social/assets/images/social/docs/admin/web/navigation.png and b/mkdocs/.cache/plugin/social/assets/images/social/docs/admin/web/navigation.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/docs/api/index.png b/mkdocs/.cache/plugin/social/assets/images/social/docs/api/index.png index 89da8140..112a4a48 100644 Binary files a/mkdocs/.cache/plugin/social/assets/images/social/docs/api/index.png and b/mkdocs/.cache/plugin/social/assets/images/social/docs/api/index.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/docs/architecture/index.png b/mkdocs/.cache/plugin/social/assets/images/social/docs/architecture/index.png index fbb2595e..3e0893f7 100644 Binary files a/mkdocs/.cache/plugin/social/assets/images/social/docs/architecture/index.png and b/mkdocs/.cache/plugin/social/assets/images/social/docs/architecture/index.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/docs/deployment/index.png b/mkdocs/.cache/plugin/social/assets/images/social/docs/deployment/index.png index 8a05a112..79ef8e47 100644 Binary files a/mkdocs/.cache/plugin/social/assets/images/social/docs/deployment/index.png and b/mkdocs/.cache/plugin/social/assets/images/social/docs/deployment/index.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/docs/getting-started/control-panel.png b/mkdocs/.cache/plugin/social/assets/images/social/docs/getting-started/control-panel.png index 40688822..04a0da49 100644 Binary files a/mkdocs/.cache/plugin/social/assets/images/social/docs/getting-started/control-panel.png and b/mkdocs/.cache/plugin/social/assets/images/social/docs/getting-started/control-panel.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/docs/getting-started/environment-variables.png b/mkdocs/.cache/plugin/social/assets/images/social/docs/getting-started/environment-variables.png index 3fc835e5..297978e0 100644 Binary files a/mkdocs/.cache/plugin/social/assets/images/social/docs/getting-started/environment-variables.png and b/mkdocs/.cache/plugin/social/assets/images/social/docs/getting-started/environment-variables.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/docs/getting-started/features.png b/mkdocs/.cache/plugin/social/assets/images/social/docs/getting-started/features.png index 0d16170a..3684c136 100644 Binary files a/mkdocs/.cache/plugin/social/assets/images/social/docs/getting-started/features.png and b/mkdocs/.cache/plugin/social/assets/images/social/docs/getting-started/features.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/docs/getting-started/first-steps.png b/mkdocs/.cache/plugin/social/assets/images/social/docs/getting-started/first-steps.png index 8a96d7e1..df194cad 100644 Binary files a/mkdocs/.cache/plugin/social/assets/images/social/docs/getting-started/first-steps.png and b/mkdocs/.cache/plugin/social/assets/images/social/docs/getting-started/first-steps.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/docs/getting-started/index.png b/mkdocs/.cache/plugin/social/assets/images/social/docs/getting-started/index.png index 87099ad6..40a241d0 100644 Binary files a/mkdocs/.cache/plugin/social/assets/images/social/docs/getting-started/index.png and b/mkdocs/.cache/plugin/social/assets/images/social/docs/getting-started/index.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/docs/getting-started/installation.png b/mkdocs/.cache/plugin/social/assets/images/social/docs/getting-started/installation.png index 1596ca5b..ed6d1b69 100644 Binary files a/mkdocs/.cache/plugin/social/assets/images/social/docs/getting-started/installation.png and b/mkdocs/.cache/plugin/social/assets/images/social/docs/getting-started/installation.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/docs/getting-started/prerequisites.png b/mkdocs/.cache/plugin/social/assets/images/social/docs/getting-started/prerequisites.png new file mode 100644 index 00000000..858da617 Binary files /dev/null and b/mkdocs/.cache/plugin/social/assets/images/social/docs/getting-started/prerequisites.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/docs/getting-started/services.png b/mkdocs/.cache/plugin/social/assets/images/social/docs/getting-started/services.png index 76d4c7dc..7a2ab199 100644 Binary files a/mkdocs/.cache/plugin/social/assets/images/social/docs/getting-started/services.png and b/mkdocs/.cache/plugin/social/assets/images/social/docs/getting-started/services.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/docs/getting-started/upgrades.png b/mkdocs/.cache/plugin/social/assets/images/social/docs/getting-started/upgrades.png index 26b9a238..b682ce09 100644 Binary files a/mkdocs/.cache/plugin/social/assets/images/social/docs/getting-started/upgrades.png and b/mkdocs/.cache/plugin/social/assets/images/social/docs/getting-started/upgrades.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/docs/index.png b/mkdocs/.cache/plugin/social/assets/images/social/docs/index.png index 1a9c1ff5..9e4f0dd8 100644 Binary files a/mkdocs/.cache/plugin/social/assets/images/social/docs/index.png and b/mkdocs/.cache/plugin/social/assets/images/social/docs/index.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/docs/phil.png b/mkdocs/.cache/plugin/social/assets/images/social/docs/phil.png index b5e1b971..090e8962 100644 Binary files a/mkdocs/.cache/plugin/social/assets/images/social/docs/phil.png and b/mkdocs/.cache/plugin/social/assets/images/social/docs/phil.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/docs/services/index.png b/mkdocs/.cache/plugin/social/assets/images/social/docs/services/index.png index 39c62e0f..00744122 100644 Binary files a/mkdocs/.cache/plugin/social/assets/images/social/docs/services/index.png and b/mkdocs/.cache/plugin/social/assets/images/social/docs/services/index.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/docs/troubleshooting/index.png b/mkdocs/.cache/plugin/social/assets/images/social/docs/troubleshooting/index.png index 2a5804e0..0d6fcbf4 100644 Binary files a/mkdocs/.cache/plugin/social/assets/images/social/docs/troubleshooting/index.png and b/mkdocs/.cache/plugin/social/assets/images/social/docs/troubleshooting/index.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/docs/user-guide/campaigns.png b/mkdocs/.cache/plugin/social/assets/images/social/docs/user-guide/campaigns.png index f1465e6c..54a7019d 100644 Binary files a/mkdocs/.cache/plugin/social/assets/images/social/docs/user-guide/campaigns.png and b/mkdocs/.cache/plugin/social/assets/images/social/docs/user-guide/campaigns.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/docs/user-guide/donations.png b/mkdocs/.cache/plugin/social/assets/images/social/docs/user-guide/donations.png index 871e7b50..83f39e04 100644 Binary files a/mkdocs/.cache/plugin/social/assets/images/social/docs/user-guide/donations.png and b/mkdocs/.cache/plugin/social/assets/images/social/docs/user-guide/donations.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/docs/user-guide/events.png b/mkdocs/.cache/plugin/social/assets/images/social/docs/user-guide/events.png index bae91417..2b23a378 100644 Binary files a/mkdocs/.cache/plugin/social/assets/images/social/docs/user-guide/events.png and b/mkdocs/.cache/plugin/social/assets/images/social/docs/user-guide/events.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/docs/user-guide/gallery.png b/mkdocs/.cache/plugin/social/assets/images/social/docs/user-guide/gallery.png index ef1bbd91..16a5dc96 100644 Binary files a/mkdocs/.cache/plugin/social/assets/images/social/docs/user-guide/gallery.png and b/mkdocs/.cache/plugin/social/assets/images/social/docs/user-guide/gallery.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/docs/user-guide/index.png b/mkdocs/.cache/plugin/social/assets/images/social/docs/user-guide/index.png index b5f145dd..65e18383 100644 Binary files a/mkdocs/.cache/plugin/social/assets/images/social/docs/user-guide/index.png and b/mkdocs/.cache/plugin/social/assets/images/social/docs/user-guide/index.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/docs/user-guide/map.png b/mkdocs/.cache/plugin/social/assets/images/social/docs/user-guide/map.png index 73369c8a..64b9fa33 100644 Binary files a/mkdocs/.cache/plugin/social/assets/images/social/docs/user-guide/map.png and b/mkdocs/.cache/plugin/social/assets/images/social/docs/user-guide/map.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/docs/user-guide/profile.png b/mkdocs/.cache/plugin/social/assets/images/social/docs/user-guide/profile.png index 2cd764c2..1c1cff6e 100644 Binary files a/mkdocs/.cache/plugin/social/assets/images/social/docs/user-guide/profile.png and b/mkdocs/.cache/plugin/social/assets/images/social/docs/user-guide/profile.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/docs/user-guide/shifts.png b/mkdocs/.cache/plugin/social/assets/images/social/docs/user-guide/shifts.png index 92063b27..272b7b01 100644 Binary files a/mkdocs/.cache/plugin/social/assets/images/social/docs/user-guide/shifts.png and b/mkdocs/.cache/plugin/social/assets/images/social/docs/user-guide/shifts.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/docs/user-guide/shop.png b/mkdocs/.cache/plugin/social/assets/images/social/docs/user-guide/shop.png index 5aabc4f9..93b98946 100644 Binary files a/mkdocs/.cache/plugin/social/assets/images/social/docs/user-guide/shop.png and b/mkdocs/.cache/plugin/social/assets/images/social/docs/user-guide/shop.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/docs/volunteer/achievements.png b/mkdocs/.cache/plugin/social/assets/images/social/docs/volunteer/achievements.png index 39951fe7..e52609d3 100644 Binary files a/mkdocs/.cache/plugin/social/assets/images/social/docs/volunteer/achievements.png and b/mkdocs/.cache/plugin/social/assets/images/social/docs/volunteer/achievements.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/docs/volunteer/canvassing.png b/mkdocs/.cache/plugin/social/assets/images/social/docs/volunteer/canvassing.png index c841b3ac..9e1b2907 100644 Binary files a/mkdocs/.cache/plugin/social/assets/images/social/docs/volunteer/canvassing.png and b/mkdocs/.cache/plugin/social/assets/images/social/docs/volunteer/canvassing.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/docs/volunteer/index.png b/mkdocs/.cache/plugin/social/assets/images/social/docs/volunteer/index.png index 3ad477ac..7e0a3707 100644 Binary files a/mkdocs/.cache/plugin/social/assets/images/social/docs/volunteer/index.png and b/mkdocs/.cache/plugin/social/assets/images/social/docs/volunteer/index.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/docs/volunteer/shifts.png b/mkdocs/.cache/plugin/social/assets/images/social/docs/volunteer/shifts.png index b8445222..e1ad9b03 100644 Binary files a/mkdocs/.cache/plugin/social/assets/images/social/docs/volunteer/shifts.png and b/mkdocs/.cache/plugin/social/assets/images/social/docs/volunteer/shifts.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/docs/volunteer/social.png b/mkdocs/.cache/plugin/social/assets/images/social/docs/volunteer/social.png index 82f490c5..eae4cdca 100644 Binary files a/mkdocs/.cache/plugin/social/assets/images/social/docs/volunteer/social.png and b/mkdocs/.cache/plugin/social/assets/images/social/docs/volunteer/social.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/includes/abbreviations.png b/mkdocs/.cache/plugin/social/assets/images/social/includes/abbreviations.png index 55598ec7..84db5ff9 100644 Binary files a/mkdocs/.cache/plugin/social/assets/images/social/includes/abbreviations.png and b/mkdocs/.cache/plugin/social/assets/images/social/includes/abbreviations.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/index.png b/mkdocs/.cache/plugin/social/assets/images/social/index.png index 898613d8..d73bf1d3 100644 Binary files a/mkdocs/.cache/plugin/social/assets/images/social/index.png and b/mkdocs/.cache/plugin/social/assets/images/social/index.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/lander.png b/mkdocs/.cache/plugin/social/assets/images/social/lander.png index dda85385..f1204b41 100644 Binary files a/mkdocs/.cache/plugin/social/assets/images/social/lander.png and b/mkdocs/.cache/plugin/social/assets/images/social/lander.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/main.png b/mkdocs/.cache/plugin/social/assets/images/social/main.png index 40940850..01859325 100644 Binary files a/mkdocs/.cache/plugin/social/assets/images/social/main.png and b/mkdocs/.cache/plugin/social/assets/images/social/main.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/partials/integrations/analytics/custom.png b/mkdocs/.cache/plugin/social/assets/images/social/partials/integrations/analytics/custom.png index 7b4531c6..4d25f221 100644 Binary files a/mkdocs/.cache/plugin/social/assets/images/social/partials/integrations/analytics/custom.png and b/mkdocs/.cache/plugin/social/assets/images/social/partials/integrations/analytics/custom.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/test-page.png b/mkdocs/.cache/plugin/social/assets/images/social/test-page.png index 6a97489c..828dfd3c 100644 Binary files a/mkdocs/.cache/plugin/social/assets/images/social/test-page.png and b/mkdocs/.cache/plugin/social/assets/images/social/test-page.png differ diff --git a/mkdocs/.cache/plugin/social/assets/images/social/test.png b/mkdocs/.cache/plugin/social/assets/images/social/test.png index 47fa3e3c..a319ed31 100644 Binary files a/mkdocs/.cache/plugin/social/assets/images/social/test.png and b/mkdocs/.cache/plugin/social/assets/images/social/test.png differ diff --git a/mkdocs/.cache/plugin/social/manifest.json b/mkdocs/.cache/plugin/social/manifest.json index fadf2fb1..5a8ac5ec 100644 --- a/mkdocs/.cache/plugin/social/manifest.json +++ b/mkdocs/.cache/plugin/social/manifest.json @@ -1,5 +1,5 @@ { - "assets/images/social/404.png": "b929ca3483431e45232307d1a278ba6f844c6032", + "assets/images/social/404.png": "57f2fd6bd9390ce0b16db2cf9255a3af255035b1", "assets/images/social/adv/ansible.png": "cb542ad9a3cc9a869258b3b1353966e1b9616a2b", "assets/images/social/adv/index.png": "faa3ec092003114c031995ba6258c4d43f4262a4", "assets/images/social/adv/vscode-ssh.png": "7c88c30c6bfb74736a308407d1a3b7cf3381d42b", @@ -8,76 +8,76 @@ "assets/images/social/blog/2025/07/10/2.png": "3c56e17d0c90ca7cd865b064d78c70e875ef70b9", "assets/images/social/blog/2025/08/01/3.png": "332b80224e75bda48c92439ad6354e7ffcab52e1", "assets/images/social/blog/2025/09/24/4.png": "840234a707ac182ce6b89203c658b312d03df58e", - "assets/images/social/blog/2026/03/22/introducing-changemaker-lite-v2.png": "c79afe23671a3a829e74debe1f3dc23f21d7100f", + "assets/images/social/blog/2026/03/22/introducing-changemaker-lite-v2.png": "04865b173dfc513a4aaa70263c9d311171a3457f", "assets/images/social/blog/2026/03/27/test-blog-post---version-3.png": "7fce7e67d83b63b507be00e7ded540dfa7bd0ed5", "assets/images/social/blog/2026/03/27/test-blog-post---version-4.png": "98cffc754c5be0c9b153444df46ed5b8b4350290", "assets/images/social/blog/2026/03/27/test-blog-post---version-5.png": "74ac268b9766c50b397e5b02513661d6abe8ebaf", "assets/images/social/blog/2026/03/27/test-blog-post---version-6.png": "c6d68bac2cdcd6b8c52256df35d542be64de7e0e", - "assets/images/social/blog/2026/03/27/test-blog-post---version-7.png": "f6cc4e78350bbf53560dbc4f119596592fe597c1", + "assets/images/social/blog/2026/03/27/test-blog-post---version-7.png": "968790eca9846a211cb33101c83bedf4f2008261", "assets/images/social/blog/2026/03/27/test-blog-post.png": "fd4dfc0ab942b7d648409d37b344d50e348150c7", "assets/images/social/blog/archive/2025.png": "08cbed159d450158ab4d79807f37adcda08bce39", - "assets/images/social/blog/archive/2026.png": "e13e604d5c2f61d08e8d14b4442ac65c268424b8", - "assets/images/social/blog/category/announcements.png": "096d6368b08dfd41a79a91e7e0fbfdc3bfd32a1b", - "assets/images/social/blog/category/platform.png": "036ba6414ed31d34d59423289658a3accefa76c0", - "assets/images/social/blog/category/testing.png": "53d1c9283a3557e3213d9c8cc28a2aa6d6824ce5", - "assets/images/social/blog/index.png": "b6fe388cb026572dfa0f42b9a6833e9fa9f95b9e", + "assets/images/social/blog/archive/2026.png": "b1fca8902961e9201a69205fe51b8a67a0651a75", + "assets/images/social/blog/category/announcements.png": "f2afe0f942265650aac482ad8796bc8cd10834c7", + "assets/images/social/blog/category/platform.png": "729705d13f917a6863191dd21711ed7f04a5172d", + "assets/images/social/blog/category/testing.png": "1438c3d0345f4e20aa2d7c4cfec517791ebca5e8", + "assets/images/social/blog/index.png": "0a85f3ee65b23a328848eaf24fe667e7d8232a3f", "assets/images/social/build/index.png": "30e75040da55694ca6485d51a35fb4d19a26b408", "assets/images/social/build/influence.png": "2961db1abb5f36d53086bf2bde9dfe19f1487809", "assets/images/social/build/map.png": "ef5bf7b7e7c3e0e527d66b3d3292918de78b7198", "assets/images/social/build/server.png": "dd492e5d54e18bee2ce13fd42d2ca647842e63b8", "assets/images/social/build/site.png": "497fd0955298e23c06e26c91dad9b99e96993af2", - "assets/images/social/comments/callback.png": "588dad7cd1206c6d8c741ee1db43e1a5326705e6", + "assets/images/social/comments/callback.png": "1320a4e03a59efdbee34945b12df2e2fb9c289fb", "assets/images/social/comments/test.png": "18d63169d742e6321dc4bb2988b8c5de61e79a28", "assets/images/social/config/cloudflare-config.png": "4addb982d2a1bda60be7cc5a5b7f31a42ee81db6", "assets/images/social/config/coder.png": "f5f93704685a0852de6e634da3ceb3d0fc549897", "assets/images/social/config/index.png": "1903ccb7f8af2454c97b8a70059119c1527826fb", "assets/images/social/config/map.png": "322276b5f574c461e2cafc4c5c8e8735bed2d25e", "assets/images/social/config/mkdocs.png": "b23831844fca01c49624fa378a20453daae66604", - "assets/images/social/docs/admin/advocacy/campaigns.png": "52e76e14d110d5be81f375a9e6480cd5e510493c", - "assets/images/social/docs/admin/advocacy/email-queue.png": "c0b346e5506d422395085c4e23d56780e467bb0f", - "assets/images/social/docs/admin/advocacy/index.png": "1c3a07d57baad1bfd626fbead731075f3d9efeef", - "assets/images/social/docs/admin/advocacy/representatives.png": "1664c253f45119e60d4c35885617e4c1147468b3", - "assets/images/social/docs/admin/advocacy/responses.png": "97d76d952f3e78f4e0dcfa2523bf0aabc08399ea", - "assets/images/social/docs/admin/broadcast/email-templates.png": "6b121b89ef5cea5b36f39032e3a50e9364e30bb3", - "assets/images/social/docs/admin/broadcast/index.png": "68d98e1866ae03ecde866e5f55aa6282cd44f941", - "assets/images/social/docs/admin/broadcast/newsletter.png": "31997f46a4f4a14ca1a688e9f54ff0c1f3552a78", - "assets/images/social/docs/admin/broadcast/sms.png": "82ab0ac704e93728f657d899c7373cd3078385d3", - "assets/images/social/docs/admin/dashboard.png": "8ea5f8248da65c60385ffcd06c32aebe2cf96717", - "assets/images/social/docs/admin/index.png": "5b118844de3687d26a701228b5e71762becf524c", - "assets/images/social/docs/admin/map/areas.png": "8bdf057cd91548b6bd037a7bcc6890ebe226f85a", - "assets/images/social/docs/admin/map/canvassing.png": "aebf754aa3de7764bb799fd53e21b0bc8b75169e", - "assets/images/social/docs/admin/map/data-quality.png": "7c9211ac719233b22b0d7e6e37243e8dc14ceac4", - "assets/images/social/docs/admin/map/index.png": "60debdcb6cdb7cf9d4821ac167b87a41c75d44bd", - "assets/images/social/docs/admin/map/locations.png": "f417a6a88236d031d4cd5d1f1bd6fc1345b3c859", - "assets/images/social/docs/admin/map/settings.png": "5df302c84797f57f90cf6b60cf5b8e810fc763e1", - "assets/images/social/docs/admin/map/shifts.png": "e99f6a91b9e74379a3a0f39301a0071645b22b96", - "assets/images/social/docs/admin/media/ads.png": "f421b5b20d652689cdc4892cbecae6cbf6e51744", - "assets/images/social/docs/admin/media/analytics.png": "a90338b99f744242aeead1c03456b456d4ace941", - "assets/images/social/docs/admin/media/curated.png": "059ca57a446f37b1b4cc8c721c379a411117e89b", - "assets/images/social/docs/admin/media/index.png": "6f5569cbc53e13e0033740ca2a032b8dc6a3874b", - "assets/images/social/docs/admin/media/library.png": "42b2e685a1b59e6d5708a66f659d4ed5103e1834", - "assets/images/social/docs/admin/media/moderation.png": "cc2abf013cd4dba618dbc292173bee37e2651d44", - "assets/images/social/docs/admin/payments/donations.png": "e3b359b7773d57e89653e8731fc7d61029467dae", - "assets/images/social/docs/admin/payments/index.png": "40957afd61d206a7fa85efb27ca07cdb0afaf0bb", - "assets/images/social/docs/admin/payments/plans.png": "fd3f6ac1dc06a3768d8cf0bc33952018b82c6ac3", - "assets/images/social/docs/admin/payments/products.png": "a1d82a8fd7d7dd79b214db2cc446e23929adfa1e", - "assets/images/social/docs/admin/payments/settings.png": "798ca4564dbe63c46a03ef9c74898f124162799c", - "assets/images/social/docs/admin/people-access.png": "1f08f874fcb4ed522a61ebb93f3621eca874be2f", - "assets/images/social/docs/admin/services/crowdsec.png": "7c4a2f5e83080895f013fc229891d6c0cc7c90bb", - "assets/images/social/docs/admin/services/index.png": "9ebdacd77c31780e7b2880fdf6804bef4438bc33", - "assets/images/social/docs/admin/services/integrations.png": "d7e8a298d30d84f7c5f497f574f147dcac84a847", - "assets/images/social/docs/admin/services/monitoring.png": "4504bad05e77b14315c7fe8f654e83b3ec6fe177", - "assets/images/social/docs/admin/services/tunnel.png": "b70bd1f40fc31a1d26c0ddc6d64e291001e4ed9f", - "assets/images/social/docs/admin/services/user-provisioning.png": "f3a1a500ffde5f2adea541ef2a98c28cb23e3e91", - "assets/images/social/docs/admin/settings.png": "b791d98178c1bb416929240d0b0153352f10d76c", - "assets/images/social/docs/admin/web/documentation.png": "42c2938d2cc1685506be91ceaebe3b0c3056ed0e", - "assets/images/social/docs/admin/web/homepage.png": "1859e592531598a9b64a0e720436f01331639657", - "assets/images/social/docs/admin/web/index.png": "9c4ba6123e450a958662bdebc2abc080ab3149a3", - "assets/images/social/docs/admin/web/landing-pages.png": "57fc3680270e71e0b992925e756c233f2f9746fd", - "assets/images/social/docs/admin/web/navigation.png": "41e86a95e9a2135b05066dac4b331fbfa794f3d6", - "assets/images/social/docs/api/index.png": "9c1f1fae7609895b60edf224e31c2be04ddd0232", - "assets/images/social/docs/architecture/index.png": "480bf36086518125519527af26a31c3be8b1d04c", - "assets/images/social/docs/deployment/index.png": "2ef61cacc08a8a0f6bc94308936ec7731fe5938a", + "assets/images/social/docs/admin/advocacy/campaigns.png": "fa7d0dbf1d05ce3d46bd74893f57c1f5318d6626", + "assets/images/social/docs/admin/advocacy/email-queue.png": "df591185d8f7b753afab2675f2ed84442f682f3b", + "assets/images/social/docs/admin/advocacy/index.png": "16a1499ac3f82bf11f3c2416299d18c635c67af3", + "assets/images/social/docs/admin/advocacy/representatives.png": "c49267d7e344b05c91846080e900065c29c93f10", + "assets/images/social/docs/admin/advocacy/responses.png": "071f023d2b9257ce3686fd7ee68e8431d227ee27", + "assets/images/social/docs/admin/broadcast/email-templates.png": "694b1e2551d4b5aaf0a11e9c8f9d990976165fd8", + "assets/images/social/docs/admin/broadcast/index.png": "d0c44803c20e97536b6d8ab83a7a7792db811cb7", + "assets/images/social/docs/admin/broadcast/newsletter.png": "c8f57b0ff3a929aa395b3555e8c50f944cc5225c", + "assets/images/social/docs/admin/broadcast/sms.png": "94e531a95225a513736aafdb9987b54ace920f14", + "assets/images/social/docs/admin/dashboard.png": "9683194a46c2908f456d14c9cc7303daf732d8e7", + "assets/images/social/docs/admin/index.png": "f2e04da736fe31d06d64d4df53be86c06a76e939", + "assets/images/social/docs/admin/map/areas.png": "dc8cadddf1207f7c6902d8ba56371ae201a0a8bd", + "assets/images/social/docs/admin/map/canvassing.png": "4fa27bf4d80f3fde318c1b5531676be10dcb78de", + "assets/images/social/docs/admin/map/data-quality.png": "221f63931ca85915414de3dcaea54ce4ba525c43", + "assets/images/social/docs/admin/map/index.png": "2048a34c5543529d8fdff85411e10cab1bdad736", + "assets/images/social/docs/admin/map/locations.png": "a99ea340ec94411e299d99af296e356df902ee06", + "assets/images/social/docs/admin/map/settings.png": "8563e14f843190fbf2249fa3c46453f3765fc03b", + "assets/images/social/docs/admin/map/shifts.png": "d83a5492c52247aba9a7ecdff411224c065c1b0a", + "assets/images/social/docs/admin/media/ads.png": "82af0bd5b633e0c594dcfcaded36ad0f699943d4", + "assets/images/social/docs/admin/media/analytics.png": "108f0ce20efd2b4f12d1fe915f7f4a7c51cc91b4", + "assets/images/social/docs/admin/media/curated.png": "c1878ac74190e66a12af7fb0bc18f264dbfa2664", + "assets/images/social/docs/admin/media/index.png": "f1b6df2def6d95f4517c8bdcedc70d6d792276a8", + "assets/images/social/docs/admin/media/library.png": "276cb356076d8c3fbd93dd9b2c358512a6a38299", + "assets/images/social/docs/admin/media/moderation.png": "2566bb52b19c8ccdda32851ab053dd421d84223f", + "assets/images/social/docs/admin/payments/donations.png": "9ac4af4d5b456adbce9c8de07f4a53c0e88a0df6", + "assets/images/social/docs/admin/payments/index.png": "bd66fa598f9f4ef8aec0ec04e89fa411f995300a", + "assets/images/social/docs/admin/payments/plans.png": "1c7a3f0ec4338e01f95dec70887b632b53aa7603", + "assets/images/social/docs/admin/payments/products.png": "b73bc09b9401c214d5a8c447e5ea1a653f49ad5d", + "assets/images/social/docs/admin/payments/settings.png": "62dce166a5dd791b1adb431a3b17354590cca5a1", + "assets/images/social/docs/admin/people-access.png": "9b917564fc7b02f6ecf9866d65f4f54a72878d58", + "assets/images/social/docs/admin/services/crowdsec.png": "5ee82e84fa13b30e3746ba411c2b709f39538f31", + "assets/images/social/docs/admin/services/index.png": "b50158c6b8edc3053c5217414988e5accde0dfe9", + "assets/images/social/docs/admin/services/integrations.png": "7f299cf160b2f9f433c81ceac72d10fb2af8275a", + "assets/images/social/docs/admin/services/monitoring.png": "2e54ca309daff1f8b470d65c054e52dffce9d4e9", + "assets/images/social/docs/admin/services/tunnel.png": "2f96d121b19e4a86292583da3088dc0c82539ae9", + "assets/images/social/docs/admin/services/user-provisioning.png": "6994de0616e270df2bda855b160db8e81bb23a5d", + "assets/images/social/docs/admin/settings.png": "f733e3266bbac0c60dbcde8ea0bafc83cc074323", + "assets/images/social/docs/admin/web/documentation.png": "7df025b3bab6e0aae65448b222ee209d61468c9b", + "assets/images/social/docs/admin/web/homepage.png": "d2bc3f84c879206d6cda58dd4809e9c7b685e3aa", + "assets/images/social/docs/admin/web/index.png": "ac63098f219c6e2feb4baacb4a5932f1bd3dc795", + "assets/images/social/docs/admin/web/landing-pages.png": "e4c6a6aefcc606a6c89d83e5d8d1d93cfdb1176a", + "assets/images/social/docs/admin/web/navigation.png": "5fdf8314ff57922588fc60a3561ae69b8b987be7", + "assets/images/social/docs/api/index.png": "821636fc5573fa03afda225052f77a75be336959", + "assets/images/social/docs/architecture/index.png": "d91bf60857284d22cc6383000fd043bb7534287c", + "assets/images/social/docs/deployment/index.png": "40b7c0d2492c1bab8d9947cf792866c1169cf83d", "assets/images/social/docs/features/achievements.png": "8d338dd93da45f6193003003978a1be7a08df3e0", "assets/images/social/docs/features/automation.png": "b55d8e0973b48a6c3017e38aa70718962cd22582", "assets/images/social/docs/features/campaigns.png": "52e76e14d110d5be81f375a9e6480cd5e510493c", @@ -108,41 +108,42 @@ "assets/images/social/docs/features/user-provisioning.png": "a4eb3646ca519dab25a7a88be5c2f415d0881736", "assets/images/social/docs/features/video-conferencing.png": "8f23fd22191ec7e59ca8b34e174a3aece810e877", "assets/images/social/docs/features/whiteboard.png": "76ea579bf566914646e99472103c9e75c705f17e", - "assets/images/social/docs/getting-started/control-panel.png": "af6419a4b71dba2b6a7db061510da2779d043626", - "assets/images/social/docs/getting-started/environment-variables.png": "a2ac6ca4cb56f9697fc7fd25f9cca6f1aa83a58b", - "assets/images/social/docs/getting-started/features.png": "ae9016c9e67134485b7f7bcb9edbbf1545be1792", - "assets/images/social/docs/getting-started/first-steps.png": "6cb7de20e49639ba1df8f6ea5a95320ce22fb489", - "assets/images/social/docs/getting-started/index.png": "c38af587f205180bab6232e517438de6db89fc88", - "assets/images/social/docs/getting-started/installation.png": "280897d4c54ab9b7c7f137a2e681461bce9ff6f5", - "assets/images/social/docs/getting-started/services.png": "64f58bca6982d60b364d3a355c66ce0f84fd5a1b", - "assets/images/social/docs/getting-started/upgrades.png": "c814d3d85a2e40a9a71fc121c9c5707b93ebcba8", - "assets/images/social/docs/index.png": "473afed8e6ed44768b1a64ad90c4a8595667a8f3", - "assets/images/social/docs/phil.png": "ffe46a0052d8c23422f82d91e03a213118869539", - "assets/images/social/docs/services/index.png": "9fcf00324266a9f7b58c7b277da2127e8882aa47", - "assets/images/social/docs/troubleshooting/index.png": "b0ffadb8b01b261dfe7dea1b55fe02a3d639b7e7", + "assets/images/social/docs/getting-started/control-panel.png": "346a96e6d85fe2c9e63c5c88df985a8760401fa4", + "assets/images/social/docs/getting-started/environment-variables.png": "1331e7c713c710c16546f70f49d54bd9a31a9200", + "assets/images/social/docs/getting-started/features.png": "35c28a26e0eafb6ec6b0eeab603d42db5210fe2e", + "assets/images/social/docs/getting-started/first-steps.png": "a164f53c692196d5a2331016e4999540498c7f98", + "assets/images/social/docs/getting-started/index.png": "ac74959998bde112e05a4fddbc3e391630139c74", + "assets/images/social/docs/getting-started/installation.png": "8867754bd8e0597a1adf63be08683ace57287fc6", + "assets/images/social/docs/getting-started/prerequisites.png": "bf4ef7d8f278b7960f51ec489c9e371de159f965", + "assets/images/social/docs/getting-started/services.png": "6d65624499df594ee550dfafa0b976650931b078", + "assets/images/social/docs/getting-started/upgrades.png": "ff223970003f5ab6b2229643afee73c24dca0a6f", + "assets/images/social/docs/index.png": "68c39437a9bc5667306ba5e4550f8ff1af18da2b", + "assets/images/social/docs/phil.png": "dd4b9c055bb257718790d2a8719f770e2a6a3eed", + "assets/images/social/docs/services/index.png": "e9ffde1d7cd34c83f676888338ef80e87f6a3cee", + "assets/images/social/docs/troubleshooting/index.png": "454be4e6a0c931f042dd0cd6158bfbf2d23495c5", "assets/images/social/docs/upgrade-test-canary.png": "3d624f3412465c0f327ac1562675ae45dd30c3ce", - "assets/images/social/docs/user-guide/campaigns.png": "298b1e317e065ba0459d4cd5779ce16a278b3c09", - "assets/images/social/docs/user-guide/donations.png": "a907a64483b99ba1d1b3841a3b5928f1a77d73e4", - "assets/images/social/docs/user-guide/events.png": "b4fed5fb2b288500035a68455b01994710c4aa98", - "assets/images/social/docs/user-guide/gallery.png": "0d24e01b34b83558126761cc91428c252aa2ea7e", - "assets/images/social/docs/user-guide/index.png": "61c9471be718b0f41b92ee23310608d75aa6b38f", - "assets/images/social/docs/user-guide/map.png": "5a5c1c04ef6cb196d099b6a41fa8609366f7260c", - "assets/images/social/docs/user-guide/profile.png": "4eaeba396aefde597e07611a6a5f1ed926a39e9b", - "assets/images/social/docs/user-guide/shifts.png": "f84e01601641307c89a4b2869f8502fd08067235", - "assets/images/social/docs/user-guide/shop.png": "b630cf93b5d0f12bd8d77856b6c5c8adc8fd9a02", - "assets/images/social/docs/volunteer/achievements.png": "8d338dd93da45f6193003003978a1be7a08df3e0", - "assets/images/social/docs/volunteer/canvassing.png": "733529980327ddc3b65566a296e74f370e269cb2", - "assets/images/social/docs/volunteer/index.png": "def884b1c33b46620a381d08cf0253d55aa8a06d", - "assets/images/social/docs/volunteer/shifts.png": "d2e17d8bcf8de9b87165812c72d0d0b54641890d", - "assets/images/social/docs/volunteer/social.png": "14b1980a43ee6eb262ee9042167d624e49a95221", + "assets/images/social/docs/user-guide/campaigns.png": "0e75f39c2c88a0e9e7a65b10168ef79ba526e031", + "assets/images/social/docs/user-guide/donations.png": "67b91d49ee3477c2497709ba830c31f9951cee3b", + "assets/images/social/docs/user-guide/events.png": "fec92928cda30fa8f646c59936625018b15ffe7f", + "assets/images/social/docs/user-guide/gallery.png": "db722fc80fe6c509a5ea5dfb76071103a321a80a", + "assets/images/social/docs/user-guide/index.png": "ca0dff0a83c8aad98a8f6b9f06012e91d3780a80", + "assets/images/social/docs/user-guide/map.png": "d0c3cf62c26fee5797a077daedda937ac36d33fe", + "assets/images/social/docs/user-guide/profile.png": "4446b641a4c4bc7f80665631a2b4b6ed45124fb1", + "assets/images/social/docs/user-guide/shifts.png": "60a7931a86eab44f8c28351937ae172c5b691302", + "assets/images/social/docs/user-guide/shop.png": "3ed61b6f07a10b7a8899c1f7904edd3a663bac6f", + "assets/images/social/docs/volunteer/achievements.png": "c6b0029cfe198847f13f6b63a4465a013741bcc0", + "assets/images/social/docs/volunteer/canvassing.png": "e5253f5735dce85d109f29e1fa146c73a44e4ee8", + "assets/images/social/docs/volunteer/index.png": "941bf4ae43288edc41e6b53ef16c9466379ecd6d", + "assets/images/social/docs/volunteer/shifts.png": "242a2113b1956bb64af0d052502f611bdf8f1ca9", + "assets/images/social/docs/volunteer/social.png": "620a160b4e1a40b61811f607a5c8d992b6481b17", "assets/images/social/how%20to/canvass.png": "83854d6765f418b3cd83651cd8828034d5a5027d", - "assets/images/social/includes/abbreviations.png": "f71db73fda50c9cdf630d2ad699f6752d608ca33", - "assets/images/social/index.png": "e78b3d8cfb2c7529a587def60aaa7e158e0fb176", - "assets/images/social/lander.png": "6ca837e423f4f2e6f786243bddefc3e55ced0818", - "assets/images/social/main.png": "42e4265e567d9c39351f377e4b72c40f808f0bac", + "assets/images/social/includes/abbreviations.png": "200e65c349f532610f5235a9fc4a0528579fa991", + "assets/images/social/index.png": "33eb9439959b758e41d6908bfc0780c36be31039", + "assets/images/social/lander.png": "2d9ca6f3c140bc1ef89c3fdf74ad16d46564f6c4", + "assets/images/social/main.png": "4bfef3ea53b34a3aeb48228d4887687493c16211", "assets/images/social/manual/index.png": "82996eb5279275b45780cc6db42d91610eac6984", "assets/images/social/manual/map.png": "322276b5f574c461e2cafc4c5c8e8735bed2d25e", - "assets/images/social/partials/integrations/analytics/custom.png": "7a6c69ad7d9364ad84c37833ae5f55b20fbea372", + "assets/images/social/partials/integrations/analytics/custom.png": "314c3bc0ac7796ff2f17743caec54cdc60da45cd", "assets/images/social/partials/test.png": "18d63169d742e6321dc4bb2988b8c5de61e79a28", "assets/images/social/phil/cost-comparison.png": "91217f5184ec32a24c77b88153b8f30740ba68ec", "assets/images/social/phil/index.png": "f26d709331027166d2fc17f40efc2470f47bd89f", @@ -159,8 +160,8 @@ "assets/images/social/services/postgresql.png": "831fb68dd3e01d9a017e59b100aaa8a455c8c112", "assets/images/social/services/static-server.png": "f36d527c80adba4bcb7778784683f429acb4ce74", "assets/images/social/test-2.png": "a6ae43d52d7c58fc106a562777e03b7da2263f83", - "assets/images/social/test-page.png": "c9d5751a1f0a4c1341336bb7d00c9bc743d33ef4", - "assets/images/social/test.png": "18d63169d742e6321dc4bb2988b8c5de61e79a28", + "assets/images/social/test-page.png": "542eaed599ca25ad5752a2a1345613ebdacfbfdb", + "assets/images/social/test.png": "7fa994f2e7122b69830be9d73a49033b5c65ac1a", "assets/images/social/testing.png": "f7aaf394b71cbe7084a6afa0e75a324ca59e23d8", "assets/images/social/v1/adv/ansible.png": "cb542ad9a3cc9a869258b3b1353966e1b9616a2b", "assets/images/social/v1/adv/index.png": "faa3ec092003114c031995ba6258c4d43f4262a4", diff --git a/mkdocs/docs/assets/favicon.svg b/mkdocs/docs/assets/favicon.svg new file mode 100644 index 00000000..bb97d6b7 --- /dev/null +++ b/mkdocs/docs/assets/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/mkdocs/docs/assets/logo.svg b/mkdocs/docs/assets/logo.svg new file mode 100644 index 00000000..bb97d6b7 --- /dev/null +++ b/mkdocs/docs/assets/logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/mkdocs/docs/assets/repo-data/admin-changemaker.lite.json b/mkdocs/docs/assets/repo-data/admin-changemaker.lite.json index e5956790..125bf9b7 100644 --- a/mkdocs/docs/assets/repo-data/admin-changemaker.lite.json +++ b/mkdocs/docs/assets/repo-data/admin-changemaker.lite.json @@ -7,10 +7,10 @@ "stars_count": 0, "forks_count": 0, "open_issues_count": 0, - "updated_at": "2026-04-02T15:39:01-06:00", + "updated_at": "2026-04-03T08:52:26-06:00", "created_at": "2025-05-28T14:54:59-06:00", "clone_url": "https://gitea.bnkops.com/admin/changemaker.lite.git", "ssh_url": "git@gitea.bnkops.com:admin/changemaker.lite.git", "default_branch": "main", - "last_build_update": "2026-04-02T15:39:01-06:00" + "last_build_update": "2026-04-03T08:52:26-06:00" } \ No newline at end of file diff --git a/mkdocs/docs/assets/repo-data/anthropics-claude-code.json b/mkdocs/docs/assets/repo-data/anthropics-claude-code.json index ea76de95..8f432434 100644 --- a/mkdocs/docs/assets/repo-data/anthropics-claude-code.json +++ b/mkdocs/docs/assets/repo-data/anthropics-claude-code.json @@ -4,13 +4,13 @@ "description": "Claude Code is an agentic coding tool that lives in your terminal, understands your codebase, and helps you code faster by executing routine tasks, explaining complex code, and handling git workflows - all through natural language commands.", "html_url": "https://github.com/anthropics/claude-code", "language": "Shell", - "stars_count": 106579, - "forks_count": 17110, - "open_issues_count": 8935, - "updated_at": "2026-04-02T23:47:09Z", + "stars_count": 110576, + "forks_count": 18412, + "open_issues_count": 9331, + "updated_at": "2026-04-07T21:20:13Z", "created_at": "2025-02-22T17:41:21Z", "clone_url": "https://github.com/anthropics/claude-code.git", "ssh_url": "git@github.com:anthropics/claude-code.git", "default_branch": "main", - "last_build_update": "2026-04-02T23:45:35Z" + "last_build_update": "2026-04-07T21:18:51Z" } \ No newline at end of file diff --git a/mkdocs/docs/assets/repo-data/coder-code-server.json b/mkdocs/docs/assets/repo-data/coder-code-server.json index f4c0ad7e..661bd9a3 100644 --- a/mkdocs/docs/assets/repo-data/coder-code-server.json +++ b/mkdocs/docs/assets/repo-data/coder-code-server.json @@ -4,13 +4,13 @@ "description": "VS Code in the browser", "html_url": "https://github.com/coder/code-server", "language": "TypeScript", - "stars_count": 76948, - "forks_count": 6581, - "open_issues_count": 182, - "updated_at": "2026-04-02T23:00:45Z", + "stars_count": 76996, + "forks_count": 6596, + "open_issues_count": 143, + "updated_at": "2026-04-07T19:48:34Z", "created_at": "2019-02-27T16:50:41Z", "clone_url": "https://github.com/coder/code-server.git", "ssh_url": "git@github.com:coder/code-server.git", "default_branch": "main", - "last_build_update": "2026-04-02T17:26:29Z" + "last_build_update": "2026-04-06T23:31:43Z" } \ No newline at end of file diff --git a/mkdocs/docs/assets/repo-data/gethomepage-homepage.json b/mkdocs/docs/assets/repo-data/gethomepage-homepage.json index 0cf80e0f..2c2dd7f5 100644 --- a/mkdocs/docs/assets/repo-data/gethomepage-homepage.json +++ b/mkdocs/docs/assets/repo-data/gethomepage-homepage.json @@ -4,13 +4,13 @@ "description": "A highly customizable homepage (or startpage / application dashboard) with Docker and service API integrations.", "html_url": "https://github.com/gethomepage/homepage", "language": "JavaScript", - "stars_count": 29293, - "forks_count": 1837, - "open_issues_count": 6, - "updated_at": "2026-04-02T23:24:40Z", + "stars_count": 29400, + "forks_count": 1853, + "open_issues_count": 2, + "updated_at": "2026-04-07T21:20:06Z", "created_at": "2022-08-24T07:29:42Z", "clone_url": "https://github.com/gethomepage/homepage.git", "ssh_url": "git@github.com:gethomepage/homepage.git", "default_branch": "dev", - "last_build_update": "2026-04-02T16:34:28Z" + "last_build_update": "2026-04-07T15:04:38Z" } \ No newline at end of file diff --git a/mkdocs/docs/assets/repo-data/go-gitea-gitea.json b/mkdocs/docs/assets/repo-data/go-gitea-gitea.json index f13cd540..ea4853b3 100644 --- a/mkdocs/docs/assets/repo-data/go-gitea-gitea.json +++ b/mkdocs/docs/assets/repo-data/go-gitea-gitea.json @@ -4,13 +4,13 @@ "description": "Git with a cup of tea! Painless self-hosted all-in-one software development service, including Git hosting, code review, team collaboration, package registry and CI/CD", "html_url": "https://github.com/go-gitea/gitea", "language": "Go", - "stars_count": 54697, - "forks_count": 6529, - "open_issues_count": 2859, - "updated_at": "2026-04-02T22:05:25Z", + "stars_count": 54776, + "forks_count": 6537, + "open_issues_count": 2822, + "updated_at": "2026-04-07T21:08:31Z", "created_at": "2016-11-01T02:13:26Z", "clone_url": "https://github.com/go-gitea/gitea.git", "ssh_url": "git@github.com:go-gitea/gitea.git", "default_branch": "main", - "last_build_update": "2026-04-02T22:05:32Z" + "last_build_update": "2026-04-07T17:49:08Z" } \ No newline at end of file diff --git a/mkdocs/docs/assets/repo-data/knadh-listmonk.json b/mkdocs/docs/assets/repo-data/knadh-listmonk.json index 8ca329c5..24a2a2c3 100644 --- a/mkdocs/docs/assets/repo-data/knadh-listmonk.json +++ b/mkdocs/docs/assets/repo-data/knadh-listmonk.json @@ -4,13 +4,13 @@ "description": "High performance, self-hosted, newsletter and mailing list manager with a modern dashboard. Single binary app.", "html_url": "https://github.com/knadh/listmonk", "language": "Go", - "stars_count": 19420, - "forks_count": 1981, - "open_issues_count": 93, - "updated_at": "2026-04-02T23:39:51Z", + "stars_count": 19473, + "forks_count": 1986, + "open_issues_count": 97, + "updated_at": "2026-04-07T17:09:29Z", "created_at": "2019-06-26T05:08:39Z", "clone_url": "https://github.com/knadh/listmonk.git", "ssh_url": "git@github.com:knadh/listmonk.git", "default_branch": "master", - "last_build_update": "2026-04-01T03:13:59Z" + "last_build_update": "2026-04-04T03:05:00Z" } \ No newline at end of file diff --git a/mkdocs/docs/assets/repo-data/lyqht-mini-qr.json b/mkdocs/docs/assets/repo-data/lyqht-mini-qr.json index 79c1e88c..21dc4b8d 100644 --- a/mkdocs/docs/assets/repo-data/lyqht-mini-qr.json +++ b/mkdocs/docs/assets/repo-data/lyqht-mini-qr.json @@ -4,13 +4,13 @@ "description": "Create & scan cute qr codes easily \ud83d\udc7e", "html_url": "https://github.com/lyqht/mini-qr", "language": "Vue", - "stars_count": 1940, + "stars_count": 1946, "forks_count": 246, "open_issues_count": 22, - "updated_at": "2026-04-02T10:46:58Z", + "updated_at": "2026-04-07T19:45:59Z", "created_at": "2023-04-21T14:20:14Z", "clone_url": "https://github.com/lyqht/mini-qr.git", "ssh_url": "git@github.com:lyqht/mini-qr.git", "default_branch": "main", - "last_build_update": "2026-04-02T01:49:35Z" + "last_build_update": "2026-04-07T07:26:33Z" } \ No newline at end of file diff --git a/mkdocs/docs/assets/repo-data/n8n-io-n8n.json b/mkdocs/docs/assets/repo-data/n8n-io-n8n.json index 877d592c..0f22479a 100644 --- a/mkdocs/docs/assets/repo-data/n8n-io-n8n.json +++ b/mkdocs/docs/assets/repo-data/n8n-io-n8n.json @@ -4,13 +4,13 @@ "description": "Fair-code workflow automation platform with native AI capabilities. Combine visual building with custom code, self-host or cloud, 400+ integrations.", "html_url": "https://github.com/n8n-io/n8n", "language": "TypeScript", - "stars_count": 182217, - "forks_count": 56419, - "open_issues_count": 1462, - "updated_at": "2026-04-02T23:45:07Z", + "stars_count": 182862, + "forks_count": 56569, + "open_issues_count": 1503, + "updated_at": "2026-04-07T21:18:33Z", "created_at": "2019-06-22T09:24:21Z", "clone_url": "https://github.com/n8n-io/n8n.git", "ssh_url": "git@github.com:n8n-io/n8n.git", "default_branch": "master", - "last_build_update": "2026-04-02T23:31:18Z" + "last_build_update": "2026-04-07T21:11:12Z" } \ No newline at end of file diff --git a/mkdocs/docs/assets/repo-data/nocodb-nocodb.json b/mkdocs/docs/assets/repo-data/nocodb-nocodb.json index 928b3fbd..f31531e3 100644 --- a/mkdocs/docs/assets/repo-data/nocodb-nocodb.json +++ b/mkdocs/docs/assets/repo-data/nocodb-nocodb.json @@ -4,13 +4,13 @@ "description": "\ud83d\udd25 \ud83d\udd25 \ud83d\udd25 A Free & Self-hostable Airtable Alternative", "html_url": "https://github.com/nocodb/nocodb", "language": "TypeScript", - "stars_count": 62599, - "forks_count": 4707, - "open_issues_count": 666, - "updated_at": "2026-04-02T22:40:11Z", + "stars_count": 62627, + "forks_count": 4714, + "open_issues_count": 668, + "updated_at": "2026-04-07T21:10:45Z", "created_at": "2017-10-29T18:51:48Z", "clone_url": "https://github.com/nocodb/nocodb.git", "ssh_url": "git@github.com:nocodb/nocodb.git", "default_branch": "develop", - "last_build_update": "2026-04-02T15:34:26Z" + "last_build_update": "2026-04-07T14:16:44Z" } \ No newline at end of file diff --git a/mkdocs/docs/assets/repo-data/ollama-ollama.json b/mkdocs/docs/assets/repo-data/ollama-ollama.json index 80d0fcbf..a3629c98 100644 --- a/mkdocs/docs/assets/repo-data/ollama-ollama.json +++ b/mkdocs/docs/assets/repo-data/ollama-ollama.json @@ -4,13 +4,13 @@ "description": "Get up and running with Kimi-K2.5, GLM-5, MiniMax, DeepSeek, gpt-oss, Qwen, Gemma and other models.", "html_url": "https://github.com/ollama/ollama", "language": "Go", - "stars_count": 166846, - "forks_count": 15277, - "open_issues_count": 2805, - "updated_at": "2026-04-02T23:45:01Z", + "stars_count": 168028, + "forks_count": 15420, + "open_issues_count": 2875, + "updated_at": "2026-04-07T21:14:42Z", "created_at": "2023-06-26T19:39:32Z", "clone_url": "https://github.com/ollama/ollama.git", "ssh_url": "git@github.com:ollama/ollama.git", "default_branch": "main", - "last_build_update": "2026-04-02T21:23:53Z" + "last_build_update": "2026-04-07T16:18:40Z" } \ No newline at end of file diff --git a/mkdocs/docs/assets/repo-data/squidfunk-mkdocs-material.json b/mkdocs/docs/assets/repo-data/squidfunk-mkdocs-material.json index 9aeff356..0f42955f 100644 --- a/mkdocs/docs/assets/repo-data/squidfunk-mkdocs-material.json +++ b/mkdocs/docs/assets/repo-data/squidfunk-mkdocs-material.json @@ -4,13 +4,13 @@ "description": "Documentation that simply works", "html_url": "https://github.com/squidfunk/mkdocs-material", "language": "Python", - "stars_count": 26441, - "forks_count": 4064, + "stars_count": 26467, + "forks_count": 4069, "open_issues_count": 1, - "updated_at": "2026-04-02T22:20:24Z", + "updated_at": "2026-04-07T19:22:44Z", "created_at": "2016-01-28T22:09:23Z", "clone_url": "https://github.com/squidfunk/mkdocs-material.git", "ssh_url": "git@github.com:squidfunk/mkdocs-material.git", "default_branch": "master", - "last_build_update": "2026-03-27T10:24:49Z" + "last_build_update": "2026-04-03T22:40:45Z" } \ No newline at end of file diff --git a/mkdocs/docs/docs/getting-started/environment-variables.md b/mkdocs/docs/docs/getting-started/environment-variables.md index 0bebc7ad..ecd8a94e 100644 --- a/mkdocs/docs/docs/getting-started/environment-variables.md +++ b/mkdocs/docs/docs/getting-started/environment-variables.md @@ -74,6 +74,7 @@ The primary database for both the Express API and the Fastify Media API (shared) |----------|---------|-------------| | `JWT_ACCESS_SECRET` | — | :material-alert-circle:{ .text-red } Secret for signing access tokens. Generate with `openssl rand -hex 32`. | | `JWT_REFRESH_SECRET` | — | :material-alert-circle:{ .text-red } Secret for signing refresh tokens. **Must differ** from the access secret. | +| `JWT_INVITE_SECRET` | — | :material-alert-circle:{ .text-red } Secret for signing volunteer invite tokens. **Must differ** from access and refresh secrets. Generate with `openssl rand -hex 32`. | | `JWT_ACCESS_EXPIRY` | `15m` | Access token lifetime. Short-lived by design. | | `JWT_REFRESH_EXPIRY` | `7d` | Refresh token lifetime. Tokens are rotated atomically on each refresh. | @@ -87,6 +88,21 @@ The primary database for both the Express API and the Fastify Media API (shared) --- +## Security Extras :material-tune-variant: + +Additional secrets for key separation. These fall back to `JWT_ACCESS_SECRET` if empty, but setting unique values is **strongly recommended** for production. + +| Variable | Default | Description | +|----------|---------|-------------| +| `GITEA_SSO_SECRET` | *(empty)* | Cookie signing secret for Gitea SSO integration. Falls back to `JWT_ACCESS_SECRET` if empty. Generate with `openssl rand -hex 32`. | +| `SERVICE_PASSWORD_SALT` | *(empty)* | Salt for deriving deterministic service passwords (Gitea, Rocket.Chat user provisioning). Falls back to `JWT_ACCESS_SECRET` if empty — rotating JWT_ACCESS_SECRET would then invalidate all provisioned service passwords. Generate with `openssl rand -hex 32`. | +| `CSP_ENABLED` | `false` | Enable Content Security Policy headers in API responses. | + +!!! warning "Key separation" + If `GITEA_SSO_SECRET` and `SERVICE_PASSWORD_SALT` are left empty, the API logs security warnings on every startup. Set unique values to isolate secret rotation and prevent one compromised key from affecting other subsystems. + +--- + ## Initial Admin Account :material-alert-circle:{ .text-red } These credentials create the first super-admin user during database seeding (`npx prisma db seed`). @@ -124,6 +140,15 @@ These credentials create the first super-admin user during database seeding (`np --- +## Rate Limiting :material-tune-variant: + +| Variable | Default | Description | +|----------|---------|-------------| +| `RATE_LIMIT_WINDOW_MS` | `900000` | Rate limit window in milliseconds (default: 15 minutes). | +| `RATE_LIMIT_MAX` | `500` | Maximum requests per window per IP. Auth endpoints have a stricter limit (10/min). | + +--- + ## Nginx Reverse Proxy | Variable | Default | Description | @@ -553,18 +578,132 @@ Remote metrics push for managing multiple Changemaker Lite instances from a cent --- +## Social, People & Analytics :material-flask: + +Feature flags for the social graph, CRM people module, and analytics dashboard. + +| Variable | Default | Description | +|----------|---------|-------------| +| `ENABLE_SOCIAL` | `false` | :material-flask: Enable the social module (friendships, challenges, spotlights, referrals). The initial default; once saved in admin Settings, the DB value is authoritative. | +| `ENABLE_PEOPLE` | `false` | :material-flask: Enable the CRM people module. The initial default; once saved in admin Settings, the DB value is authoritative. | +| `ENABLE_ANALYTICS` | `false` | :material-flask: Enable the analytics dashboard with visitor tracking and geographic insights. The initial default; once saved in admin Settings, the DB value is authoritative. | + +--- + +## GeoIP (MaxMind GeoLite2) :material-flask: + +Geographic IP lookup for analytics visitor location tracking. Requires a free MaxMind account. + +| Variable | Default | Description | +|----------|---------|-------------| +| `MAXMIND_ACCOUNT_ID` | *(empty)* | MaxMind account ID. [Sign up free](https://www.maxmind.com/en/geolite2/signup). | +| `MAXMIND_LICENSE_KEY` | *(empty)* | MaxMind license key. When set, the GeoLite2-City database auto-downloads at startup. | +| `GEOIP_DB_PATH` | `/data/geoip/GeoLite2-City.mmdb` | Path to the GeoLite2 database file inside the container. | + +--- + +## Control Panel Agent (CCP) :material-flask: + +Remote management agent for the Changemaker Control Panel — enables centralized multi-instance management. + +| Variable | Default | Description | +|----------|---------|-------------| +| `ENABLE_CCP_AGENT` | `false` | :material-flask: Enable the CCP remote management agent. | +| `CCP_URL` | *(empty)* | URL of the Changemaker Control Panel server. | +| `CCP_INVITE_CODE` | *(empty)* | One-time invite code for agent registration with the control panel. | +| `CCP_AGENT_URL` | *(empty)* | How the CCP can reach this agent (must be externally accessible). | +| `CCP_AGENT_PORT` | `7443` | Agent listener port. | + +--- + +## Container Registry :material-tune-variant: + +Settings for pulling pre-built production images from the Gitea container registry. + +| Variable | Default | Description | +|----------|---------|-------------| +| `GITEA_REGISTRY` | `gitea.bnkops.com/admin` | Registry hostname and namespace for pulling images. | +| `IMAGE_TAG` | *(empty)* | Image tag to pull. Set to a commit SHA or `latest` for pre-built images. Leave empty (defaults to `local`) to build from source. | +| `COMPOSE_PROFILES` | *(empty)* | Docker Compose profiles to activate. Set to `monitoring` to include Prometheus/Grafana/Alertmanager in every `docker compose up -d`. | +| `GITEA_REGISTRY_USER` | `admin` | Registry username for `docker login` and the registry status API endpoint. | +| `GITEA_REGISTRY_PASS` | *(empty)* | Registry password for the status API endpoint. For `docker push/pull`, use `docker login gitea.bnkops.com`. | +| `GITEA_REGISTRY_API_TOKEN` | *(empty)* | API token for the **remote** registry (gitea.bnkops.com). Used by `build-release.sh --upload` to publish release tarballs. Create at Gitea → User Settings → Applications. **Not** the same as `GITEA_API_TOKEN`. | + +--- + +## Docker / Container Management :material-tune-variant: + +Internal settings for the admin dashboard's service status panel and container management. + +| Variable | Default | Description | +|----------|---------|-------------| +| `DOCKER_NETWORK_NAME` | `changemaker-lite` | Docker bridge network name. Used by the dashboard to auto-discover containers. | +| `DOCKER_PROXY_URL` | `http://docker-socket-proxy:2375` | Read-only Docker socket proxy URL for container inspection. | +| `NEWT_CONTAINER_NAME` | `newt-changemaker` | Newt tunnel container name (for restart/status checks). | +| `NEWT_COMPOSE_SERVICE` | `newt` | Docker Compose service name for the Newt container. | + +--- + +## Embed Proxy Ports :material-tune-variant: + +Dedicated nginx ports for iframe embedding services in the admin dashboard without requiring DNS/subdomains. Change these to avoid port conflicts when running multiple instances on one host. + +| Variable | Default | Description | +|----------|---------|-------------| +| `NOCODB_EMBED_PORT` | `8881` | NocoDB iframe port. | +| `N8N_EMBED_PORT` | `8882` | n8n iframe port. | +| `GITEA_EMBED_PORT` | `8883` | Gitea iframe port. | +| `MAILHOG_EMBED_PORT` | `8884` | MailHog iframe port. | +| `MINI_QR_EMBED_PORT` | `8885` | Mini QR iframe port. | +| `EXCALIDRAW_EMBED_PORT` | `8886` | Excalidraw iframe port. | +| `HOMEPAGE_EMBED_PORT` | `8887` | Homepage iframe port. | +| `VAULTWARDEN_EMBED_PORT` | `8890` | Vaultwarden iframe port. | +| `ROCKETCHAT_EMBED_PORT` | `8891` | Rocket.Chat iframe port. | +| `GANCIO_EMBED_PORT` | `8892` | Gancio iframe port. | +| `JITSI_EMBED_PORT` | `8893` | Jitsi iframe port. | +| `GRAFANA_EMBED_PORT` | `8894` | Grafana iframe port. | +| `ALERTMANAGER_EMBED_PORT` | `8895` | Alertmanager iframe port. | + +--- + +## Gitea Docs Version History :material-tune-variant: + +Settings for the documentation version history feature (backed by Gitea repository commits). + +| Variable | Default | Description | +|----------|---------|-------------| +| `GITEA_DOCS_REPO` | `admin/changemaker.lite` | Gitea repository path for docs version history. | +| `GITEA_DOCS_PREFIX` | `mkdocs/docs` | Path prefix within the repository where documentation files live. | +| `GITEA_DOCS_BRANCH` | `v2` | Git branch to query for version history. | +| `GITEA_ADMIN_PASSWORD` | *(empty)* | Gitea admin password. Used **once** during initial setup to create an API token, then can be cleared. | + +--- + +## Prisma CLI (Host-Side) :material-tune-variant: + +| Variable | Default | Description | +|----------|---------|-------------| +| `DATABASE_URL` | `postgresql://changemaker:YOUR_POSTGRES_PASSWORD@localhost:5433/changemaker_v2` | Full PostgreSQL connection string. **Only used when running Prisma CLI on the host** (`npx prisma migrate dev`). Docker containers resolve the database hostname internally via Docker Compose environment variables. | + +--- + ## Generating Secrets Use these commands to generate all required secrets at once: ```bash -# JWT secrets (two separate values) +# JWT secrets (three separate values) echo "JWT_ACCESS_SECRET=$(openssl rand -hex 32)" echo "JWT_REFRESH_SECRET=$(openssl rand -hex 32)" +echo "JWT_INVITE_SECRET=$(openssl rand -hex 32)" # Encryption key (must differ from JWT secrets) echo "ENCRYPTION_KEY=$(openssl rand -hex 32)" +# Security extras (key separation) +echo "GITEA_SSO_SECRET=$(openssl rand -hex 32)" +echo "SERVICE_PASSWORD_SALT=$(openssl rand -hex 32)" + # Database and Redis passwords echo "V2_POSTGRES_PASSWORD=$(openssl rand -hex 24)" echo "REDIS_PASSWORD=$(openssl rand -hex 24)" @@ -616,6 +755,7 @@ echo "JITSI_JVB_AUTH_PASSWORD=$(openssl rand -hex 16)" REDIS_PASSWORD=... JWT_ACCESS_SECRET=... JWT_REFRESH_SECRET=... + JWT_INVITE_SECRET=... ENCRYPTION_KEY=... INITIAL_ADMIN_PASSWORD=... ``` diff --git a/mkdocs/docs/docs/getting-started/index.md b/mkdocs/docs/docs/getting-started/index.md index 2fb6157d..ace25d57 100644 --- a/mkdocs/docs/docs/getting-started/index.md +++ b/mkdocs/docs/docs/getting-started/index.md @@ -101,6 +101,7 @@ See [Services Overview](services.md) for the complete catalog with ports, featur ## Next Steps +- [Prerequisites](prerequisites.md) — external services checklist (domain, SMTP, tunnel) - [Installation](installation.md) — detailed setup walkthrough and manual configuration - [Services Overview](services.md) — complete service catalog (30+ containers) - [Environment Variables](environment-variables.md) — complete `.env` reference diff --git a/mkdocs/docs/docs/getting-started/installation.md b/mkdocs/docs/docs/getting-started/installation.md index fb1835f3..310b2c75 100644 --- a/mkdocs/docs/docs/getting-started/installation.md +++ b/mkdocs/docs/docs/getting-started/installation.md @@ -15,6 +15,13 @@ search: Changemaker Lite runs as a set of Docker containers orchestrated by Docker Compose. The `config.sh` wizard handles all configuration — or you can set things up manually. +!!! info "Have your external services ready?" + For a **production deployment**, you'll need a domain name, SMTP email provider, and a reverse tunnel (like Pangolin) or public IP with SSL. Gather these **before** running the wizard — it makes the process much smoother. + + **:material-arrow-right: [Prerequisites & External Services](prerequisites.md)** — full checklist with provider recommendations + + *For local development/evaluation, you can skip this — Docker and MailHog handle everything out of the box.* + --- ## Prerequisites @@ -24,6 +31,8 @@ Changemaker Lite runs as a set of Docker containers orchestrated by Docker Compo - A Linux server (Ubuntu 22.04+ recommended) or macOS for development - At least **2 GB RAM** for core services, **4 GB** for the full stack - A domain name (optional for development, recommended for production) +- An SMTP provider for production email delivery (see [Prerequisites](prerequisites.md#3-smtp-email-provider)) +- A reverse tunnel or public IP for internet access (see [Prerequisites](prerequisites.md#2-a-reverse-tunnel-or-public-ip)) --- diff --git a/mkdocs/docs/docs/getting-started/prerequisites.md b/mkdocs/docs/docs/getting-started/prerequisites.md new file mode 100644 index 00000000..2d57d067 --- /dev/null +++ b/mkdocs/docs/docs/getting-started/prerequisites.md @@ -0,0 +1,196 @@ +--- +title: Prerequisites & External Services +description: What you need before installing Changemaker Lite — domain, email, tunnel, and optional third-party accounts. +icon: material/clipboard-check-outline +tags: + - guide + - getting-started + - operator + - planning +search: + boost: 2 +--- + +# Prerequisites & External Services + +Before running the installer, gather the external services and accounts listed below. Having these ready makes the configuration wizard a smooth, uninterrupted process. + +!!! tip "Don't have these yet?" + You can still install Changemaker Lite in **development mode** with just Docker — no domain, tunnel, or SMTP required. MailHog captures all emails locally. But for a **production deployment** serving real users, you'll need the items on this page. + +--- + +## Required for Production + +### 1. A Domain Name + +You need a domain (e.g., `betteredmonton.org`) that you control. Changemaker Lite uses **subdomain routing** — the platform creates subdomains like: + +| Subdomain | Purpose | +|-----------|---------| +| `app.yourdomain.org` | Admin dashboard + all public pages | +| `api.yourdomain.org` | Backend API | +| `docs.yourdomain.org` | Documentation site | +| `git.yourdomain.org` | Git hosting (Gitea) | +| `events.yourdomain.org` | Event calendar (Gancio) | +| ... and 10+ more | See [Services Overview](services.md) | + +You'll point your domain's DNS to wherever your tunnel or server is hosted. **Wildcard DNS** (`*.yourdomain.org`) is the simplest approach. + +**Where to get one:** Any registrar — Namecheap, Cloudflare Registrar, Porkbun, etc. Budget ~$10–15/year. + +--- + +### 2. A Reverse Tunnel or Public IP + +Your server needs to be reachable from the internet. Most home/office networks don't have a static public IP, so you need a **reverse tunnel** service that gives your server a stable public address with SSL. + +Changemaker Lite has built-in support for **[Pangolin](https://github.com/fosrl/pangolin)** — a self-hosted, open-source tunnel that handles SSL certificates, subdomain routing, and access control automatically. The admin dashboard includes a one-click Pangolin setup wizard. + +**What you need:** + +- A Pangolin server (or access to a shared one) +- An API key and Organization ID +- Your domain's DNS pointed at the Pangolin server + +**Alternatives:** Cloudflare Tunnel (free tier available), a VPS with a public IP, or any reverse proxy with SSL termination. + +--- + +### 3. SMTP Email Provider + +Production deployments need a real SMTP server to send emails — campaign messages, password resets, volunteer invitations, and newsletter delivery all depend on it. + +**What you need:** + +| Setting | Example | +|---------|---------| +| SMTP Host | `smtp.protonmail.ch` | +| SMTP Port | `587` (STARTTLS) or `465` (TLS) | +| SMTP Username | `your-account@provider.com` | +| SMTP Password | Your SMTP password or app-specific password | + +**Popular SMTP providers:** + +| Provider | Free Tier | Notes | +|----------|-----------|-------| +| **Proton Mail** | Included with paid plan | Privacy-focused, recommended for advocacy | +| **Mailgun** | 100 emails/day (FLEX) | Good deliverability, easy setup | +| **Amazon SES** | 62,000/month (from EC2) | Cheapest at scale, requires verification | +| **Brevo (Sendinblue)** | 300 emails/day | Simple setup, good free tier | +| **Resend** | 100 emails/day | Developer-friendly, modern API | + +!!! warning "Shared hosting SMTP" + Avoid using shared hosting SMTP (GoDaddy, Bluehost, etc.) for campaign emails — they have low sending limits and poor deliverability. Use a dedicated transactional email provider. + +--- + +### 4. A Linux Server + +Changemaker Lite runs on any Linux server with Docker. Minimum specs: + +| Component | Minimum | Recommended | +|-----------|---------|-------------| +| **RAM** | 2 GB (core only) | 4 GB (full stack) | +| **Disk** | 10 GB | 20+ GB (with media uploads) | +| **CPU** | 1 vCPU | 2+ vCPU | +| **OS** | Any Linux with Docker | Ubuntu 22.04+ LTS | + +**Options:** A VPS from DigitalOcean, Hetzner, Linode, or a spare machine on your network. If using a tunnel (Pangolin), the server doesn't need a public IP. + +--- + +## Optional (Enhance Your Deployment) + +These are not required but unlock additional platform features: + +### Stripe Account (Payments) + +For accepting donations, selling merchandise, or managing membership plans. Create a free account at [stripe.com](https://stripe.com). You'll enter your Stripe API keys in the admin settings page (they're stored encrypted in the database). + +### Mapbox or Google Maps API Key (Geocoding) + +Improves address geocoding accuracy for the mapping module. The platform works without these (using free OpenStreetMap providers), but paid providers are more reliable for bulk operations. + +- **Mapbox:** Free tier includes 100,000 requests/month. [Sign up](https://www.mapbox.com/pricing). +- **Google Maps:** Free tier includes $200/month credit (~40,000 requests). [Sign up](https://developers.google.com/maps). + +### MaxMind GeoLite2 (Analytics) + +For geographic analytics (visitor location tracking). Free account at [maxmind.com](https://www.maxmind.com/en/geolite2/signup). The database auto-downloads at startup when credentials are configured. + +### Android Phone with Termux (SMS Campaigns) + +The SMS module uses a physical Android phone as an SMS gateway via the Termux app. This is a unique feature for grassroots campaigns that want to send SMS without expensive third-party services. + +### Jitsi Meet Requirements (Video Conferencing) + +If enabling the self-hosted video conferencing feature: + +- Server's **public IP address** (for NAT traversal) +- **UDP port 10000** open in your firewall (for media traffic) + +--- + +## Pre-Installation Checklist + +Use this checklist to make sure you're ready: + +- [ ] **Domain name** registered and DNS accessible +- [ ] **DNS configured** — wildcard `*.yourdomain.org` or individual subdomain records pointing to your tunnel/server +- [ ] **Tunnel or public IP** — Pangolin credentials (API key + Org ID), or server with public IP + SSL +- [ ] **SMTP credentials** — host, port, username, password from your email provider +- [ ] **Linux server** with Docker 24+ and Docker Compose v2 installed +- [ ] **OpenSSL** installed (for generating secrets during setup) +- [ ] *(Optional)* Stripe account for payments +- [ ] *(Optional)* Mapbox or Google Maps API key for geocoding +- [ ] *(Optional)* MaxMind account for geographic analytics + +--- + +## :material-briefcase-outline:{ .lg } Bunker Operations Can Help { #managed-services } + +Setting up infrastructure — domains, tunnels, SMTP, servers — can be the hardest part of self-hosting. **Bunker Operations** offers managed infrastructure for organizations running Changemaker Lite: + +
+ +- :material-tunnel:{ .lg .middle } **Managed Pangolin Tunnel** + + --- + + Pre-configured tunnel with SSL, wildcard DNS, and automatic subdomain routing. Just plug in your API key and go. + +- :material-email-fast:{ .lg .middle } **SMTP Relay** + + --- + + High-deliverability transactional email with SPF/DKIM/DMARC already configured for your domain. + +- :material-server:{ .lg .middle } **Hosted Servers** + + --- + + Pre-provisioned Linux servers with Docker, monitoring, and automatic backups — ready for a one-command install. + +- :material-wrench:{ .lg .middle } **Setup Assistance** + + --- + + We'll walk you through the full deployment — from domain registration to your first campaign launch. + +
+ +!!! quote "Built by organizers, for organizers" + Bunker Operations exists so campaign teams can focus on **building power** — not wrestling with infrastructure. We provide the plumbing so you can focus on the mission. + + **Get in touch:** [bnkops.com](https://bnkops.com) or email `hello@bnkops.com` + +--- + +## Next Steps + +Once you have your prerequisites ready: + +- [Installation](installation.md) — run the configuration wizard and start services +- [Environment Variables](environment-variables.md) — complete reference for every `.env` setting +- [Deployment Guide](../deployment/index.md) — production setup with SSL and tunneling diff --git a/mkdocs/docs/overrides/lander.html b/mkdocs/docs/overrides/lander.html index eeada5aa..5ad14a22 100644 --- a/mkdocs/docs/overrides/lander.html +++ b/mkdocs/docs/overrides/lander.html @@ -5,6 +5,7 @@ Changemaker Lite - Grow Power. Don't Rent It. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + +
+ + +
+
+ + +
+ + + + + + + + + + + + + + + + + + + + +
+ + + +
+ +
+ + + + + + + +
+
+ + + +
+
+
+ + + + + + + +
+
+
+ + + + + + + +
+ + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + +

Prerequisites & External Services

+

Before running the installer, gather the external services and accounts listed below. Having these ready makes the configuration wizard a smooth, uninterrupted process.

+
+

Don't have these yet?

+

You can still install Changemaker Lite in development mode with just Docker — no domain, tunnel, or SMTP required. MailHog captures all emails locally. But for a production deployment serving real users, you'll need the items on this page.

+
+
+

Required for Production

+

1. A Domain Name

+

You need a domain (e.g., betteredmonton.org) that you control. Changemaker Lite uses subdomain routing — the platform creates subdomains like:

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
SubdomainPurpose
app.yourdomain.orgAdmin dashboard + all public pages
api.yourdomain.orgBackend API
docs.yourdomain.orgDocumentation site
git.yourdomain.orgGit hosting (Gitea)
events.yourdomain.orgEvent calendar (Gancio)
... and 10+ moreSee Services Overview
+

You'll point your domain's DNS to wherever your tunnel or server is hosted. Wildcard DNS (*.yourdomain.org) is the simplest approach.

+

Where to get one: Any registrar — Namecheap, Cloudflare Registrar, Porkbun, etc. Budget ~$10–15/year.

+
+

2. A Reverse Tunnel or Public IP

+

Your server needs to be reachable from the internet. Most home/office networks don't have a static public IP, so you need a reverse tunnel service that gives your server a stable public address with SSL.

+

Changemaker Lite has built-in support for Pangolin — a self-hosted, open-source tunnel that handles SSL certificates, subdomain routing, and access control automatically. The admin dashboard includes a one-click Pangolin setup wizard.

+

What you need:

+
    +
  • A Pangolin server (or access to a shared one)
  • +
  • An API key and Organization ID
  • +
  • Your domain's DNS pointed at the Pangolin server
  • +
+

Alternatives: Cloudflare Tunnel (free tier available), a VPS with a public IP, or any reverse proxy with SSL termination.

+
+

3. SMTP Email Provider

+

Production deployments need a real SMTP server to send emails — campaign messages, password resets, volunteer invitations, and newsletter delivery all depend on it.

+

What you need:

+ + + + + + + + + + + + + + + + + + + + + + + + + +
SettingExample
SMTP Hostsmtp.protonmail.ch
SMTP Port587 (STARTTLS) or 465 (TLS)
SMTP Usernameyour-account@provider.com
SMTP PasswordYour SMTP password or app-specific password
+

Popular SMTP providers:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ProviderFree TierNotes
Proton MailIncluded with paid planPrivacy-focused, recommended for advocacy
Mailgun100 emails/day (FLEX)Good deliverability, easy setup
Amazon SES62,000/month (from EC2)Cheapest at scale, requires verification
Brevo (Sendinblue)300 emails/daySimple setup, good free tier
Resend100 emails/dayDeveloper-friendly, modern API
+
+

Shared hosting SMTP

+

Avoid using shared hosting SMTP (GoDaddy, Bluehost, etc.) for campaign emails — they have low sending limits and poor deliverability. Use a dedicated transactional email provider.

+
+
+

4. A Linux Server

+

Changemaker Lite runs on any Linux server with Docker. Minimum specs:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ComponentMinimumRecommended
RAM2 GB (core only)4 GB (full stack)
Disk10 GB20+ GB (with media uploads)
CPU1 vCPU2+ vCPU
OSAny Linux with DockerUbuntu 22.04+ LTS
+

Options: A VPS from DigitalOcean, Hetzner, Linode, or a spare machine on your network. If using a tunnel (Pangolin), the server doesn't need a public IP.

+
+

Optional (Enhance Your Deployment)

+

These are not required but unlock additional platform features:

+

Stripe Account (Payments)

+

For accepting donations, selling merchandise, or managing membership plans. Create a free account at stripe.com. You'll enter your Stripe API keys in the admin settings page (they're stored encrypted in the database).

+

Mapbox or Google Maps API Key (Geocoding)

+

Improves address geocoding accuracy for the mapping module. The platform works without these (using free OpenStreetMap providers), but paid providers are more reliable for bulk operations.

+
    +
  • Mapbox: Free tier includes 100,000 requests/month. Sign up.
  • +
  • Google Maps: Free tier includes $200/month credit (~40,000 requests). Sign up.
  • +
+

MaxMind GeoLite2 (Analytics)

+

For geographic analytics (visitor location tracking). Free account at maxmind.com. The database auto-downloads at startup when credentials are configured.

+

Android Phone with Termux (SMS Campaigns)

+

The SMS module uses a physical Android phone as an SMS gateway via the Termux app. This is a unique feature for grassroots campaigns that want to send SMS without expensive third-party services.

+

Jitsi Meet Requirements (Video Conferencing)

+

If enabling the self-hosted video conferencing feature:

+
    +
  • Server's public IP address (for NAT traversal)
  • +
  • UDP port 10000 open in your firewall (for media traffic)
  • +
+
+

Pre-Installation Checklist

+

Use this checklist to make sure you're ready:

+
    +
  • Domain name registered and DNS accessible
  • +
  • DNS configured — wildcard *.yourdomain.org or individual subdomain records pointing to your tunnel/server
  • +
  • Tunnel or public IP — Pangolin credentials (API key + Org ID), or server with public IP + SSL
  • +
  • SMTP credentials — host, port, username, password from your email provider
  • +
  • Linux server with Docker 24+ and Docker Compose v2 installed
  • +
  • OpenSSL installed (for generating secrets during setup)
  • +
  • (Optional) Stripe account for payments
  • +
  • (Optional) Mapbox or Google Maps API key for geocoding
  • +
  • (Optional) MaxMind account for geographic analytics
  • +
+
+

Bunker Operations Can Help

+

Setting up infrastructure — domains, tunnels, SMTP, servers — can be the hardest part of self-hosting. Bunker Operations offers managed infrastructure for organizations running Changemaker Lite:

+
+
    +
  • +

    Managed Pangolin Tunnel

    +
    +

    Pre-configured tunnel with SSL, wildcard DNS, and automatic subdomain routing. Just plug in your API key and go.

    +
  • +
  • +

    SMTP Relay

    +
    +

    High-deliverability transactional email with SPF/DKIM/DMARC already configured for your domain.

    +
  • +
  • +

    Hosted Servers

    +
    +

    Pre-provisioned Linux servers with Docker, monitoring, and automatic backups — ready for a one-command install.

    +
  • +
  • +

    Setup Assistance

    +
    +

    We'll walk you through the full deployment — from domain registration to your first campaign launch.

    +
  • +
+
+
+

Built by organizers, for organizers

+

Bunker Operations exists so campaign teams can focus on building power — not wrestling with infrastructure. We provide the plumbing so you can focus on the mission.

+

Get in touch: bnkops.com or email hello@bnkops.com

+
+
+

Next Steps

+

Once you have your prerequisites ready:

+ + + + + + + + + + + + + + + + + + +
+ + + + + +
+ + + + + + + + +
+
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/docs/getting-started/services/index.html b/mkdocs/site/docs/getting-started/services/index.html index 806c035e..e8c635da 100644 --- a/mkdocs/site/docs/getting-started/services/index.html +++ b/mkdocs/site/docs/getting-started/services/index.html @@ -25,7 +25,7 @@ - + @@ -71,67 +71,6 @@ - - - - - - - @@ -1165,7 +1104,7 @@ body.cm-search-active .md-header--cm-hidden .md-search__output {