Compare commits

...

7 commits

15 changed files with 452 additions and 94 deletions

26
db.go
View file

@ -140,6 +140,11 @@ func migrate(db *sql.DB) error {
department_id INTEGER NOT NULL REFERENCES departments(id) ON DELETE CASCADE, department_id INTEGER NOT NULL REFERENCES departments(id) ON DELETE CASCADE,
PRIMARY KEY (participant_id, department_id) PRIMARY KEY (participant_id, department_id)
); );
CREATE TABLE IF NOT EXISTS sso_nonces (
nonce TEXT PRIMARY KEY,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
`) `)
return err return err
} }
@ -1350,6 +1355,27 @@ func (app *App) listOpenShiftsForDept(deptID int) ([]Shift, error) {
ORDER BY s.day, s.position, s.start_time`, deptID) ORDER BY s.day, s.position, s.start_time`, deptID)
} }
// --- SSO Nonces ---
func (app *App) createSSONonce(nonce string) error {
_, err := app.db.Exec(`INSERT INTO sso_nonces (nonce) VALUES (?)`, nonce)
return err
}
func (app *App) consumeSSONonce(nonce string) (bool, error) {
res, err := app.db.Exec(
`DELETE FROM sso_nonces WHERE nonce = ? AND created_at > datetime('now', '-10 minutes')`, nonce)
if err != nil {
return false, err
}
n, _ := res.RowsAffected()
return n > 0, nil
}
func (app *App) cleanExpiredNonces() {
app.db.Exec(`DELETE FROM sso_nonces WHERE created_at < datetime('now', '-10 minutes')`)
}
// --- Helpers --- // --- Helpers ---
func now() string { func now() string {

View file

@ -1,6 +1,6 @@
<script> <script>
import { onMount } from 'svelte' import { onMount } from 'svelte'
import { getSession, clearSession } from './db.js' import { getSession, saveSession, clearSession } from './db.js'
import { syncPull, startSSE, startSyncLoop } from './sync.js' import { syncPull, startSSE, startSyncLoop } from './sync.js'
import Login from './pages/Login.svelte' import Login from './pages/Login.svelte'
import Dashboard from './pages/Dashboard.svelte' import Dashboard from './pages/Dashboard.svelte'
@ -25,6 +25,7 @@
let route = $state(window.location.pathname) let route = $state(window.location.pathname)
let updateAvailable = $state(false) let updateAvailable = $state(false)
let mobileNavOpen = $state(false) let mobileNavOpen = $state(false)
let ssoError = $state('')
// Check if this is a public page (no auth needed) // Check if this is a public page (no auth needed)
const kioskToken = $derived(route.match(/^\/v\/([A-Z0-9]+)/i)?.[1] ?? '') const kioskToken = $derived(route.match(/^\/v\/([A-Z0-9]+)/i)?.[1] ?? '')
@ -36,6 +37,7 @@
history.pushState(null, '', path) history.pushState(null, '', path)
route = path route = path
mobileNavOpen = false mobileNavOpen = false
window.scrollTo(0, 0)
} }
async function checkVersion() { async function checkVersion() {
@ -54,7 +56,32 @@
loading = false loading = false
return return
} }
// Handle SSO callback in URL fragment
const hash = window.location.hash
if (hash.startsWith('#sso_token=')) {
const token = decodeURIComponent(hash.slice('#sso_token='.length))
history.replaceState(null, '', '/')
try {
const res = await fetch('/api/me', { headers: { Authorization: `Bearer ${token}` } })
if (res.ok) {
const user = await res.json()
await saveSession(token, user)
session = { token, user }
} else {
ssoError = 'SSO login failed. Please try again.'
}
} catch {
ssoError = 'SSO login failed. Please try again.'
}
} else if (hash.startsWith('#sso_error=')) {
ssoError = decodeURIComponent(hash.slice('#sso_error='.length))
history.replaceState(null, '', '/')
}
if (!session) {
session = await getSession() session = await getSession()
}
loading = false loading = false
if (session) { if (session) {
await syncPull() await syncPull()
@ -102,7 +129,7 @@
{:else if isConfirmEmail} {:else if isConfirmEmail}
<ConfirmEmail /> <ConfirmEmail />
{:else if !session} {:else if !session}
<Login onlogin={onLogin} /> <Login onlogin={onLogin} error={ssoError} />
{:else if roles.length === 1 && roles[0] === 'gatekeeper'} {:else if roles.length === 1 && roles[0] === 'gatekeeper'}
<!-- Gate-only users get the full-screen GateKiosk instead of the standard layout --> <!-- Gate-only users get the full-screen GateKiosk instead of the standard layout -->
<GateKiosk {session} {onLogout} /> <GateKiosk {session} {onLogout} />

View file

@ -1,4 +1,4 @@
import { db } from './db.js' import { db, clearSession } from './db.js'
async function getToken() { async function getToken() {
const session = await db.session.get(1) const session = await db.session.get(1)
@ -17,7 +17,7 @@ export async function apiFetch(path, options = {}) {
const res = await fetch(path, { ...options, headers }) const res = await fetch(path, { ...options, headers })
if (res.status === 401) { if (res.status === 401) {
await db.session.clear() await clearSession()
window.location.pathname = '/login' window.location.pathname = '/login'
throw new Error('unauthorized') throw new Error('unauthorized')
} }
@ -118,6 +118,10 @@ export const api = {
resetDepartments: () => apiJSON('/api/settings/reset-departments', { method: 'POST' }), resetDepartments: () => apiJSON('/api/settings/reset-departments', { method: 'POST' }),
resetVolunteerShifts: () => apiJSON('/api/settings/reset-volunteer-shifts', { method: 'POST' }), resetVolunteerShifts: () => apiJSON('/api/settings/reset-volunteer-shifts', { method: 'POST' }),
}, },
sso: {
enabled: () => kioskFetch('/api/public/sso-enabled'),
init: () => kioskFetch('/api/sso/init'),
},
signup: { signup: {
config: () => kioskFetch('/api/public/signup-config'), config: () => kioskFetch('/api/public/signup-config'),
submit: (data) => kioskFetch('/api/public/signup', { method: 'POST', body: JSON.stringify(data) }), submit: (data) => kioskFetch('/api/public/signup', { method: 'POST', body: JSON.stringify(data) }),

View file

@ -66,6 +66,9 @@ a:hover { color: var(--c-accent-h); }
/* Cards */ /* Cards */
.card { background: var(--c-surface); border: 1px solid var(--c-border); border-radius: var(--radius-lg); padding: 1.25rem; } .card { background: var(--c-surface); border: 1px solid var(--c-border); border-radius: var(--radius-lg); padding: 1.25rem; }
.card + .card, .card + form, form + .card, form + form { margin-top: 1.5rem; }
.card-title { font-size: 0.95rem; font-weight: 700; margin-bottom: 1rem; }
.card-hint { font-size: 0.78rem; color: var(--c-muted); }
/* Stats */ /* Stats */
.stats { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 1rem; margin-bottom: 1.5rem; } .stats { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 1rem; margin-bottom: 1.5rem; }
@ -103,8 +106,15 @@ input, select, textarea {
width: 100%; font-family: var(--font); width: 100%; font-family: var(--font);
transition: border-color var(--transition); transition: border-color var(--transition);
} }
input[type="checkbox"] { width: auto; }
input[type="date"], input[type="time"], input[type="datetime-local"] { -webkit-appearance: none; appearance: none; min-height: 2.35rem; }
input:focus, select:focus, textarea:focus { outline: none; border-color: var(--c-accent); } input:focus, select:focus, textarea:focus { outline: none; border-color: var(--c-accent); }
input::placeholder { color: var(--c-muted); } input::placeholder { color: var(--c-muted); }
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; }
.form-grid-3 { display: grid; grid-template-columns: 1fr 1fr auto; gap: 1rem; align-items: end; }
.form-grid .full { grid-column: 1 / -1; }
.checkbox-label { display: inline-flex; align-items: center; gap: 0.5rem; font-size: 0.875rem; cursor: pointer; }
.checkbox-label-sm { display: inline-flex; align-items: center; gap: 0.3rem; font-size: 0.82rem; cursor: pointer; color: var(--c-text); }
/* Search */ /* Search */
.search-bar { display: flex; gap: 0.5rem; align-items: center; margin-bottom: 1rem; } .search-bar { display: flex; gap: 0.5rem; align-items: center; margin-bottom: 1rem; }
@ -129,6 +139,7 @@ tr:hover td { background: rgba(255,255,255,0.02); }
font-size: 0.72rem; font-weight: 600; font-size: 0.72rem; font-weight: 600;
text-transform: uppercase; letter-spacing: 0.04em; text-transform: uppercase; letter-spacing: 0.04em;
} }
* + .badge { margin-left: 0.3rem; }
.badge-checked { background: rgba(34,197,94,0.15); color: var(--c-success); } .badge-checked { background: rgba(34,197,94,0.15); color: var(--c-success); }
.badge-confirmed { background: rgba(99,102,241,0.15); color: var(--c-accent-h); } .badge-confirmed { background: rgba(99,102,241,0.15); color: var(--c-accent-h); }
.badge-registered { background: rgba(14,165,233,0.15); color: #0ea5e9; } .badge-registered { background: rgba(14,165,233,0.15); color: #0ea5e9; }
@ -234,6 +245,7 @@ tr:hover td { background: rgba(255,255,255,0.02); }
td { display: inline; padding: 0; border: none; } td { display: inline; padding: 0; border: none; }
td:empty { display: none; } td:empty { display: none; }
/* Forms */ /* Forms — 16px prevents iOS auto-zoom on focus */
.form-grid { grid-template-columns: 1fr !important; } input, select, textarea { font-size: 16px; }
.form-grid, .form-grid-3 { grid-template-columns: 1fr !important; }
} }

View file

@ -160,7 +160,7 @@
<p class="text-muted" style="font-size:0.85rem;margin-top:2rem"> <p class="text-muted" style="font-size:0.85rem;margin-top:2rem">
Welcome, <strong style="color:var(--c-text)">{session?.user?.preferred_name}</strong> Welcome, <strong style="color:var(--c-text)">{session?.user?.preferred_name}</strong>
· {#each roles as r}<span class="badge badge-role" style="margin-right:0.25rem">{r}</span>{/each} · {#each roles as r}<span class="badge badge-role">{r}</span>{/each}
</p> </p>
</div> </div>

View file

@ -101,7 +101,7 @@
{#if showAdd && canCreate} {#if showAdd && canCreate}
<div class="card" style="margin-bottom:1.5rem"> <div class="card" style="margin-bottom:1.5rem">
<form onsubmit={addDept}> <form onsubmit={addDept}>
<div class="form-grid" style="display:grid;grid-template-columns:1fr 1fr auto;gap:1rem;align-items:end"> <div class="form-grid-3">
<div class="form-group" style="margin-bottom:0"> <div class="form-group" style="margin-bottom:0">
<label for="d-name">Name *</label> <label for="d-name">Name *</label>
<input id="d-name" bind:value={newName} required placeholder="e.g. Security" /> <input id="d-name" bind:value={newName} required placeholder="e.g. Security" />
@ -112,7 +112,7 @@
</div> </div>
<div class="form-group" style="margin-bottom:0"> <div class="form-group" style="margin-bottom:0">
<label for="d-color">Color</label> <label for="d-color">Color</label>
<input id="d-color" type="color" bind:value={newColor} style="width:60px;padding:0.2rem;height:2.3rem;cursor:pointer" /> <input id="d-color" type="color" bind:value={newColor} class="color-input" />
</div> </div>
</div> </div>
<div class="actions" style="margin-top:1rem"> <div class="actions" style="margin-top:1rem">
@ -191,6 +191,7 @@
</div> </div>
<style> <style>
.color-input { width: 60px; padding: 0.2rem; height: 2.3rem; cursor: pointer; }
@media (max-width: 640px) { @media (max-width: 640px) {
.td-name { width: 100%; } .td-name { width: 100%; }
.td-desc { width: 100%; } .td-desc { width: 100%; }

View file

@ -1,13 +1,25 @@
<script> <script>
import { onMount } from 'svelte'
import { api } from '../api.js' import { api } from '../api.js'
import { saveSession } from '../db.js' import { saveSession } from '../db.js'
let { onlogin } = $props() let { onlogin, error: externalError = '' } = $props()
let email = $state('') let email = $state('')
let password = $state('') let password = $state('')
let error = $state('') let error = $state('')
$effect(() => { if (externalError) error = externalError })
let loading = $state(false) let loading = $state(false)
let ssoEnabled = $state(false)
let ssoLoading = $state(false)
onMount(async () => {
try {
const res = await api.sso.enabled()
ssoEnabled = res.enabled
} catch {}
})
async function submit(e) { async function submit(e) {
e.preventDefault() e.preventDefault()
@ -23,6 +35,18 @@
loading = false loading = false
} }
} }
async function startSSO() {
error = ''
ssoLoading = true
try {
const { redirect_url } = await api.sso.init()
window.location.href = redirect_url
} catch (err) {
error = err.message || 'SSO failed'
ssoLoading = false
}
}
</script> </script>
<div class="login-wrap"> <div class="login-wrap">
@ -45,5 +69,28 @@
{loading ? 'Signing in…' : 'Sign in'} {loading ? 'Signing in…' : 'Sign in'}
</button> </button>
</form> </form>
{#if ssoEnabled}
<div class="sso-divider"><span>or</span></div>
<button class="btn btn-ghost" style="width:100%" onclick={startSSO} disabled={ssoLoading}>
{ssoLoading ? 'Redirecting…' : 'Log in with Discourse'}
</button>
{/if}
</div> </div>
</div> </div>
<style>
.sso-divider {
display: flex;
align-items: center;
margin: 1rem 0;
gap: 0.75rem;
color: var(--c-muted);
font-size: 0.8rem;
}
.sso-divider::before,
.sso-divider::after {
content: '';
flex: 1;
border-top: 1px solid var(--c-border);
}
</style>

View file

@ -248,7 +248,7 @@
{#if showAdd && canManage} {#if showAdd && canManage}
<div class="card" style="margin-bottom:1.5rem"> <div class="card" style="margin-bottom:1.5rem">
<form onsubmit={addParticipant}> <form onsubmit={addParticipant}>
<div class="form-grid" style="display:grid;grid-template-columns:1fr 1fr;gap:1rem"> <div class="form-grid">
<div class="form-group"> <div class="form-group">
<label for="p-name">Preferred Name</label> <label for="p-name">Preferred Name</label>
<input id="p-name" bind:value={newName} placeholder="Preferred name" /> <input id="p-name" bind:value={newName} placeholder="Preferred name" />

View file

@ -275,7 +275,7 @@
{#if showAdd && canManage} {#if showAdd && canManage}
<div class="card" style="margin-bottom:1.5rem"> <div class="card" style="margin-bottom:1.5rem">
<form onsubmit={addShift}> <form onsubmit={addShift}>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem"> <div class="form-grid">
<div class="form-group"> <div class="form-group">
<label for="s-dept">Department *</label> <label for="s-dept">Department *</label>
<select id="s-dept" bind:value={newDeptID} required> <select id="s-dept" bind:value={newDeptID} required>
@ -380,10 +380,11 @@
<span class="board-cap">{assigned.length}</span> <span class="board-cap">{assigned.length}</span>
{/if} {/if}
{#if hasConflict} {#if hasConflict}
<span class="badge badge-lead" style="margin-left:0.3rem">⚠ conflict</span> <span class="badge badge-lead">⚠ conflict</span>
{/if} {/if}
</div> </div>
{#if canManage}
<div class="board-shift-actions"> <div class="board-shift-actions">
<button class="btn btn-ghost btn-sm" onclick={() => startEdit(shift)}>Edit</button> <button class="btn btn-ghost btn-sm" onclick={() => startEdit(shift)}>Edit</button>
<button class="btn btn-ghost btn-sm" title="Move up" <button class="btn btn-ghost btn-sm" title="Move up"
@ -392,6 +393,7 @@
onclick={() => reorder(shift.id, 1, rows)}>↓</button> onclick={() => reorder(shift.id, 1, rows)}>↓</button>
<button class="btn btn-danger btn-sm" onclick={() => deleteShift(shift)}>Delete</button> <button class="btn btn-danger btn-sm" onclick={() => deleteShift(shift)}>Delete</button>
</div> </div>
{/if}
</div> </div>
<!-- Assigned volunteers --> <!-- Assigned volunteers -->
@ -406,13 +408,14 @@
{#if checkConflict(volunteer.id, shift.id, $allVolunteerShifts ?? [], $allShifts ?? [])} {#if checkConflict(volunteer.id, shift.id, $allVolunteerShifts ?? [], $allShifts ?? [])}
<span title="Scheduling conflict" style="color:var(--c-warn)"></span> <span title="Scheduling conflict" style="color:var(--c-warn)"></span>
{/if} {/if}
<button class="board-vol-remove" onclick={() => unassign(shift.id, volunteer.id)} title="Remove">×</button> {#if canManage}<button class="board-vol-remove" onclick={() => unassign(shift.id, volunteer.id)} title="Remove">×</button>{/if}
</div> </div>
{/each} {/each}
</div> </div>
{/if} {/if}
<!-- Assign volunteer --> <!-- Assign volunteer -->
{#if canManage}
{#if assigningShiftID === shift.id} {#if assigningShiftID === shift.id}
<div class="board-assign-row"> <div class="board-assign-row">
<select bind:value={assignVolID} style="width:auto"> <select bind:value={assignVolID} style="width:auto">
@ -436,6 +439,7 @@
<button class="board-add-vol" onclick={() => startAssign(shift.id)}>+ Assign volunteer</button> <button class="board-add-vol" onclick={() => startAssign(shift.id)}>+ Assign volunteer</button>
{/if} {/if}
{/if} {/if}
{/if}
</div> </div>
{/each} {/each}
{/each} {/each}

View file

@ -7,6 +7,7 @@
let saving = $state(false) let saving = $state(false)
let savingEvent = $state(false) let savingEvent = $state(false)
let testing = $state(false) let testing = $state(false)
let resetting = $state(false)
let error = $state('') let error = $state('')
let success = $state('') let success = $state('')
@ -26,6 +27,8 @@
let eventEndDate = $state('') let eventEndDate = $state('')
let eventTimezone = $state('') let eventTimezone = $state('')
const timezones = Intl.supportedValuesOf('timeZone') const timezones = Intl.supportedValuesOf('timeZone')
let discourseSSOUrl = $state('')
let discourseSSOSecret = $state('')
let shiftSignupsOpen = $state(false) let shiftSignupsOpen = $state(false)
let togglingSignups = $state(false) let togglingSignups = $state(false)
@ -49,6 +52,8 @@
baseURL = s.base_url ?? '' baseURL = s.base_url ?? ''
noteLabel = s.volunteer_note_label ?? 'Additional note' noteLabel = s.volunteer_note_label ?? 'Additional note'
noteRequired = s.volunteer_note_required ?? false noteRequired = s.volunteer_note_required ?? false
discourseSSOUrl = s.discourse_sso_url ?? ''
discourseSSOSecret = ''
shiftSignupsOpen = s.shift_signups_open ?? false shiftSignupsOpen = s.shift_signups_open ?? false
} catch (err) { } catch (err) {
error = err.message error = err.message
@ -89,14 +94,17 @@
smtp_host: smtpHost, smtp_host: smtpHost,
smtp_port: smtpPort, smtp_port: smtpPort,
smtp_user: smtpUser, smtp_user: smtpUser,
smtp_password: smtpPassword, // empty = keep existing smtp_password: smtpPassword,
smtp_from: smtpFrom, smtp_from: smtpFrom,
smtp_from_name: smtpFromName, smtp_from_name: smtpFromName,
base_url: baseURL, base_url: baseURL,
volunteer_note_label: noteLabel, volunteer_note_label: noteLabel,
volunteer_note_required: noteRequired, volunteer_note_required: noteRequired,
discourse_sso_url: discourseSSOUrl,
discourse_sso_secret: discourseSSOSecret,
}) })
smtpPassword = '' smtpPassword = ''
discourseSSOSecret = ''
success = 'Settings saved.' success = 'Settings saved.'
} catch (err) { } catch (err) {
error = err.message error = err.message
@ -123,7 +131,9 @@
} }
async function resetModel(label, fn) { async function resetModel(label, fn) {
if (resetting) return
if (!confirm(`PERMANENTLY DELETE all ${label}? This cannot be undone.`)) return if (!confirm(`PERMANENTLY DELETE all ${label}? This cannot be undone.`)) return
resetting = true
error = '' error = ''
success = '' success = ''
try { try {
@ -131,6 +141,8 @@
success = `Deleted ${result.deleted} ${label}.` success = `Deleted ${result.deleted} ${label}.`
} catch (err) { } catch (err) {
error = err.message error = err.message
} finally {
resetting = false
} }
} }
@ -166,14 +178,14 @@
<div class="text-muted">Loading…</div> <div class="text-muted">Loading…</div>
{:else} {:else}
<form onsubmit={saveEvent}> <form onsubmit={saveEvent}>
<div class="card" style="margin-bottom:1.5rem"> <div class="card">
<h2 style="font-size:0.95rem;font-weight:700;margin-bottom:1rem">Event</h2> <h2 class="card-title">Event</h2>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem"> <div class="form-grid">
<div class="form-group" style="grid-column:1/-1"> <div class="form-group full">
<label for="e-name">Event Name *</label> <label for="e-name">Event Name *</label>
<input id="e-name" bind:value={eventName} required placeholder="My Event 2026" /> <input id="e-name" bind:value={eventName} required placeholder="My Event 2026" />
</div> </div>
<div class="form-group" style="grid-column:1/-1"> <div class="form-group full">
<label for="e-venue">Venue</label> <label for="e-venue">Venue</label>
<input id="e-venue" bind:value={eventVenue} placeholder="Location name" /> <input id="e-venue" bind:value={eventVenue} placeholder="Location name" />
</div> </div>
@ -185,7 +197,7 @@
<label for="e-end">End Date *</label> <label for="e-end">End Date *</label>
<input id="e-end" type="date" bind:value={eventEndDate} required /> <input id="e-end" type="date" bind:value={eventEndDate} required />
</div> </div>
<div class="form-group" style="grid-column:1/-1"> <div class="form-group full">
<label for="e-tz">Timezone</label> <label for="e-tz">Timezone</label>
<input id="e-tz" bind:value={eventTimezone} placeholder="America/Chicago" list="tz-list" /> <input id="e-tz" bind:value={eventTimezone} placeholder="America/Chicago" list="tz-list" />
<datalist id="tz-list"> <datalist id="tz-list">
@ -204,11 +216,11 @@
</form> </form>
<form onsubmit={save}> <form onsubmit={save}>
<div class="card" style="margin-bottom:1.5rem"> <div class="card">
<h2 style="font-size:0.95rem;font-weight:700;margin-bottom:1rem">SMTP Email</h2> <h2 class="card-title">SMTP Email</h2>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem"> <div class="form-grid">
<div class="form-group" style="grid-column:1"> <div class="form-group">
<label for="s-host">SMTP Host</label> <label for="s-host">SMTP Host</label>
<input id="s-host" bind:value={smtpHost} placeholder="smtp.fastmail.com" /> <input id="s-host" bind:value={smtpHost} placeholder="smtp.fastmail.com" />
</div> </div>
@ -236,10 +248,27 @@
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="s-url">Base URL <span class="text-muted" style="font-weight:400">(for kiosk links in emails)</span></label> <label for="s-url">Base URL <span class="card-hint" style="font-weight:400">(for kiosk links in emails)</span></label>
<input id="s-url" bind:value={baseURL} placeholder="https://events.example.com" /> <input id="s-url" bind:value={baseURL} placeholder="https://events.example.com" />
</div> </div>
<h2 class="card-title" style="margin-top:1.5rem">Discourse SSO</h2>
<p class="card-hint" style="margin-bottom:1rem">
Enable DiscourseConnect SSO so users can log in with their Discourse account.
Set the same secret in your Discourse admin under Connect &gt; discourse connect secret.
</p>
<div class="form-grid">
<div class="form-group full">
<label for="sso-url">Discourse URL</label>
<input id="sso-url" bind:value={discourseSSOUrl} placeholder="https://forum.example.com" />
</div>
<div class="form-group full">
<label for="sso-secret">SSO Secret</label>
<input id="sso-secret" type="password" bind:value={discourseSSOSecret}
placeholder="Leave blank to keep existing" autocomplete="new-password" />
</div>
</div>
<div class="actions"> <div class="actions">
<button type="submit" class="btn btn-primary" disabled={saving}> <button type="submit" class="btn btn-primary" disabled={saving}>
{saving ? 'Saving…' : 'Save Settings'} {saving ? 'Saving…' : 'Save Settings'}
@ -249,8 +278,8 @@
</form> </form>
<!-- Test email --> <!-- Test email -->
<div class="card" style="margin-bottom:1.5rem"> <div class="card">
<h2 style="font-size:0.95rem;font-weight:700;margin-bottom:1rem">Test Email</h2> <h2 class="card-title">Test Email</h2>
<div style="display:flex;gap:0.5rem;align-items:flex-end"> <div style="display:flex;gap:0.5rem;align-items:flex-end">
<div class="form-group" style="flex:1;margin-bottom:0"> <div class="form-group" style="flex:1;margin-bottom:0">
<label for="s-test">Send to</label> <label for="s-test">Send to</label>
@ -263,24 +292,24 @@
</div> </div>
<!-- Volunteer Signup --> <!-- Volunteer Signup -->
<div class="card" style="margin-bottom:1.5rem"> <div class="card">
<h2 style="font-size:0.95rem;font-weight:700;margin-bottom:1rem">Volunteer Signup</h2> <h2 class="card-title">Volunteer Signup</h2>
<div class="form-group"> <div class="form-group">
<label for="s-note-label">Note Field Label</label> <label for="s-note-label">Note Field Label</label>
<input id="s-note-label" bind:value={noteLabel} placeholder="Additional note" /> <input id="s-note-label" bind:value={noteLabel} placeholder="Additional note" />
</div> </div>
<label style="display:flex;align-items:center;gap:0.5rem;font-size:0.875rem;cursor:pointer"> <label class="checkbox-label">
<input type="checkbox" bind:checked={noteRequired} /> <input type="checkbox" bind:checked={noteRequired} />
Note field is required Note field is required
</label> </label>
<p class="text-muted" style="font-size:0.78rem;margin-top:0.75rem"> <p class="card-hint" style="margin-top:0.75rem">
Signup form: <a href="/volunteer-signup" target="_blank" style="color:var(--c-accent)">/volunteer-signup</a> Signup form: <a href="/volunteer-signup" target="_blank" style="color:var(--c-accent)">/volunteer-signup</a>
</p> </p>
</div> </div>
<!-- Shift Signups --> <!-- Shift Signups -->
<div class="card" style="margin-bottom:1.5rem"> <div class="card">
<h2 style="font-size:0.95rem;font-weight:700;margin-bottom:1rem">Shift Signups</h2> <h2 class="card-title">Shift Signups</h2>
<div style="display:flex;align-items:center;gap:1rem"> <div style="display:flex;align-items:center;gap:1rem">
<span style="font-size:0.875rem"> <span style="font-size:0.875rem">
Status: <strong>{shiftSignupsOpen ? 'Open' : 'Closed'}</strong> Status: <strong>{shiftSignupsOpen ? 'Open' : 'Closed'}</strong>
@ -295,7 +324,7 @@
</button> </button>
</div> </div>
{#if !shiftSignupsOpen} {#if !shiftSignupsOpen}
<p class="text-muted" style="font-size:0.78rem;margin-top:0.75rem"> <p class="card-hint" style="margin-top:0.75rem">
Opening signups will email all confirmed volunteers their shift signup links. Opening signups will email all confirmed volunteers their shift signup links.
</p> </p>
{/if} {/if}
@ -303,24 +332,24 @@
<!-- Data Management --> <!-- Data Management -->
<div class="card"> <div class="card">
<h2 style="font-size:0.95rem;font-weight:700;margin-bottom:0.5rem">Data Management</h2> <h2 class="card-title" style="margin-bottom:0.5rem">Data Management</h2>
<p class="text-muted" style="font-size:0.78rem;margin-bottom:1rem"> <p class="card-hint" style="margin-bottom:1rem">
Permanently delete all records of a given type. This cannot be undone. Permanently delete all records of a given type. This cannot be undone.
</p> </p>
<div style="display:flex;flex-wrap:wrap;gap:0.5rem"> <div style="display:flex;flex-wrap:wrap;gap:0.5rem">
<button class="btn btn-danger" onclick={() => resetModel('tickets', api.settings.resetTickets)}> <button class="btn btn-danger" disabled={resetting} onclick={() => resetModel('tickets', api.settings.resetTickets)}>
Delete All Tickets Delete All Tickets
</button> </button>
<button class="btn btn-danger" onclick={() => resetModel('volunteers', api.settings.resetVolunteers)}> <button class="btn btn-danger" disabled={resetting} onclick={() => resetModel('volunteers', api.settings.resetVolunteers)}>
Delete All Volunteers Delete All Volunteers
</button> </button>
<button class="btn btn-danger" onclick={() => resetModel('shifts', api.settings.resetShifts)}> <button class="btn btn-danger" disabled={resetting} onclick={() => resetModel('shifts', api.settings.resetShifts)}>
Delete All Shifts Delete All Shifts
</button> </button>
<button class="btn btn-danger" onclick={() => resetModel('departments', api.settings.resetDepartments)}> <button class="btn btn-danger" disabled={resetting} onclick={() => resetModel('departments', api.settings.resetDepartments)}>
Delete All Departments Delete All Departments
</button> </button>
<button class="btn btn-danger" onclick={() => resetModel('volunteer shift assignments', api.settings.resetVolunteerShifts)}> <button class="btn btn-danger" disabled={resetting} onclick={() => resetModel('volunteer shift assignments', api.settings.resetVolunteerShifts)}>
Delete All Shift Assignments Delete All Shift Assignments
</button> </button>
</div> </div>

View file

@ -149,7 +149,7 @@
{#if showAdd} {#if showAdd}
<div class="card" style="margin-bottom:1.5rem"> <div class="card" style="margin-bottom:1.5rem">
<form onsubmit={addUser}> <form onsubmit={addUser}>
<div class="form-grid" style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:1rem"> <div class="form-grid-3">
<div class="form-group"> <div class="form-group">
<label for="u-email">Email *</label> <label for="u-email">Email *</label>
<input id="u-email" type="email" bind:value={newEmail} required placeholder="email@example.com" autocomplete="off" /> <input id="u-email" type="email" bind:value={newEmail} required placeholder="email@example.com" autocomplete="off" />
@ -167,8 +167,8 @@
<span style="font-size:0.82rem;color:var(--c-muted);font-weight:500">Roles</span> <span style="font-size:0.82rem;color:var(--c-muted);font-weight:500">Roles</span>
<div style="display:flex;flex-wrap:wrap;gap:0.5rem;margin-top:0.35rem"> <div style="display:flex;flex-wrap:wrap;gap:0.5rem;margin-top:0.35rem">
{#each availableRoles as r} {#each availableRoles as r}
<label style="display:flex;align-items:center;gap:0.35rem;cursor:pointer;font-size:0.875rem;color:var(--c-text)"> <label class="checkbox-label">
<input type="checkbox" style="width:auto" <input type="checkbox"
checked={newRoles.includes(r)} checked={newRoles.includes(r)}
onchange={() => newRoles = toggleItem(r, newRoles)} /> onchange={() => newRoles = toggleItem(r, newRoles)} />
{roleLabel(r)} {roleLabel(r)}
@ -181,8 +181,8 @@
<span style="font-size:0.82rem;color:var(--c-muted);font-weight:500">Departments</span> <span style="font-size:0.82rem;color:var(--c-muted);font-weight:500">Departments</span>
<div style="display:flex;flex-wrap:wrap;gap:0.5rem;margin-top:0.35rem"> <div style="display:flex;flex-wrap:wrap;gap:0.5rem;margin-top:0.35rem">
{#each $allDepts ?? [] as d} {#each $allDepts ?? [] as d}
<label style="display:flex;align-items:center;gap:0.35rem;cursor:pointer;font-size:0.875rem;color:var(--c-text)"> <label class="checkbox-label">
<input type="checkbox" style="width:auto" <input type="checkbox"
checked={newDeptIDs.includes(d.id)} checked={newDeptIDs.includes(d.id)}
onchange={() => newDeptIDs = toggleItem(d.id, newDeptIDs)} /> onchange={() => newDeptIDs = toggleItem(d.id, newDeptIDs)} />
<span class="dept-dot" style="background:{d.color}"></span> <span class="dept-dot" style="background:{d.color}"></span>
@ -228,8 +228,8 @@
<td> <td>
<div style="display:flex;flex-wrap:wrap;gap:0.4rem"> <div style="display:flex;flex-wrap:wrap;gap:0.4rem">
{#each availableRoles as r} {#each availableRoles as r}
<label style="display:flex;align-items:center;gap:0.3rem;cursor:pointer;font-size:0.82rem;color:var(--c-text)"> <label class="checkbox-label-sm">
<input type="checkbox" style="width:auto" <input type="checkbox"
checked={editRoles.includes(r)} checked={editRoles.includes(r)}
onchange={() => editRoles = toggleItem(r, editRoles)} /> onchange={() => editRoles = toggleItem(r, editRoles)} />
{roleLabel(r)} {roleLabel(r)}
@ -241,8 +241,8 @@
{#if ($allDepts ?? []).length > 0} {#if ($allDepts ?? []).length > 0}
<div style="display:flex;flex-wrap:wrap;gap:0.4rem"> <div style="display:flex;flex-wrap:wrap;gap:0.4rem">
{#each $allDepts ?? [] as d} {#each $allDepts ?? [] as d}
<label style="display:flex;align-items:center;gap:0.3rem;cursor:pointer;font-size:0.82rem;color:var(--c-text)"> <label class="checkbox-label-sm">
<input type="checkbox" style="width:auto" <input type="checkbox"
checked={editDeptIDs.includes(d.id)} checked={editDeptIDs.includes(d.id)}
onchange={() => editDeptIDs = toggleItem(d.id, editDeptIDs)} /> onchange={() => editDeptIDs = toggleItem(d.id, editDeptIDs)} />
{d.name} {d.name}
@ -268,11 +268,11 @@
<td class="td-name"> <td class="td-name">
<strong>{u.preferred_name || u.email}</strong> <strong>{u.preferred_name || u.email}</strong>
{#if u.id === me} {#if u.id === me}
<span class="badge badge-role" style="margin-left:0.4rem">you</span> <span class="badge badge-role">you</span>
{/if} {/if}
<br><span class="text-muted" style="font-size:0.8rem">{u.email}</span> <br><span class="text-muted" style="font-size:0.8rem">{u.email}</span>
</td> </td>
<td>{#each u.roles ?? [] as r}<span class="badge badge-role" style="margin-right:0.25rem">{roleLabel(r)}</span>{/each}</td> <td>{#each u.roles ?? [] as r}<span class="badge badge-role">{roleLabel(r)}</span>{/each}</td>
<td class="text-muted">{deptNamesFor(u.department_ids || [])}</td> <td class="text-muted">{deptNamesFor(u.department_ids || [])}</td>
<td class="td-actions"> <td class="td-actions">
<div class="actions"> <div class="actions">

View file

@ -24,6 +24,7 @@
let editIsLead = $state(false) let editIsLead = $state(false)
let editNote = $state('') let editNote = $state('')
let saving = $state(false) let saving = $state(false)
let confirmingID = $state(null)
const roles = $derived(session?.user?.roles ?? []) const roles = $derived(session?.user?.roles ?? [])
function hasRole(...allowed) { return roles.some(r => allowed.includes(r)) } function hasRole(...allowed) { return roles.some(r => allowed.includes(r)) }
@ -76,11 +77,15 @@
} }
async function confirmVolunteer(v) { async function confirmVolunteer(v) {
if (confirmingID) return
confirmingID = v.id
try { try {
const updated = await api.volunteers.confirm(v.id) const updated = await api.volunteers.confirm(v.id)
await db.volunteers.put(updated) await db.volunteers.put(updated)
} catch (err) { } catch (err) {
error = err.message error = err.message
} finally {
confirmingID = null
} }
} }
@ -181,7 +186,7 @@
{#if showAdd && canManage} {#if showAdd && canManage}
<div class="card" style="margin-bottom:1.5rem"> <div class="card" style="margin-bottom:1.5rem">
<form onsubmit={addVolunteer}> <form onsubmit={addVolunteer}>
<div class="form-grid" style="display:grid;grid-template-columns:1fr 1fr;gap:1rem"> <div class="form-grid">
<div class="form-group"> <div class="form-group">
<label for="v-name">Preferred Name *</label> <label for="v-name">Preferred Name *</label>
<input id="v-name" bind:value={newName} required placeholder="What they go by" /> <input id="v-name" bind:value={newName} required placeholder="What they go by" />
@ -209,8 +214,8 @@
<input id="v-note" bind:value={newNote} placeholder="Optional note" /> <input id="v-note" bind:value={newNote} placeholder="Optional note" />
</div> </div>
<div style="margin-bottom:1rem"> <div style="margin-bottom:1rem">
<label style="display:flex;align-items:center;gap:0.5rem;cursor:pointer"> <label class="checkbox-label">
<input type="checkbox" style="width:auto" bind:checked={newIsLead} /> <input type="checkbox" bind:checked={newIsLead} />
Department lead Department lead
</label> </label>
</div> </div>
@ -281,8 +286,8 @@
</select> </select>
</td> </td>
<td class="td-edit-checks"> <td class="td-edit-checks">
<label style="display:flex;align-items:center;gap:0.4rem;cursor:pointer;white-space:nowrap"> <label class="checkbox-label-sm" style="white-space:nowrap">
<input type="checkbox" style="width:auto" bind:checked={editIsLead} /> Co-Lead <input type="checkbox" bind:checked={editIsLead} /> Co-Lead
</label> </label>
</td> </td>
<td class="td-edit-note"> <td class="td-edit-note">
@ -301,12 +306,12 @@
<td class="td-name"> <td class="td-name">
<strong>{v.name}</strong> <strong>{v.name}</strong>
{#if v.is_lead} {#if v.is_lead}
<span class="badge badge-lead" style="margin-left:0.4rem">Co-Lead</span> <span class="badge badge-lead">Co-Lead</span>
{/if} {/if}
{#if !v.participant_id} {#if !v.participant_id}
<span class="badge badge-unchecked" style="margin-left:0.4rem" title="Not linked to a participant">No ticket</span> <span class="badge badge-unchecked" title="Not linked to a participant">No ticket</span>
{:else if !participantHasTickets(v.participant_id)} {:else if !participantHasTickets(v.participant_id)}
<span class="badge badge-partial" style="margin-left:0.4rem" title="No ticket on file">No ticket</span> <span class="badge badge-partial" title="No ticket on file">No ticket</span>
{/if} {/if}
{#if participant?.ticket_name && participant.ticket_name !== v.name} {#if participant?.ticket_name && participant.ticket_name !== v.name}
<div class="text-muted" style="font-size:0.78rem">Ticket: {participant.ticket_name}</div> <div class="text-muted" style="font-size:0.78rem">Ticket: {participant.ticket_name}</div>
@ -349,7 +354,7 @@
{#if canManage} {#if canManage}
<td class="td-actions"> <td class="td-actions">
{#if canConfirm && v.email_confirmed && !v.confirmed} {#if canConfirm && v.email_confirmed && !v.confirmed}
<button class="btn btn-primary btn-sm" onclick={() => confirmVolunteer(v)}>Confirm</button> <button class="btn btn-primary btn-sm" onclick={() => confirmVolunteer(v)} disabled={confirmingID === v.id}>Confirm</button>
{/if} {/if}
<button class="btn btn-ghost btn-sm" onclick={() => startEdit(v)}>Edit</button> <button class="btn btn-ghost btn-sm" onclick={() => startEdit(v)}>Edit</button>
<button class="btn btn-danger btn-sm" onclick={() => deleteVolunteer(v)}>Delete</button> <button class="btn btn-danger btn-sm" onclick={() => deleteVolunteer(v)}>Delete</button>

View file

@ -27,6 +27,14 @@ func (app *App) handleGetSettings(w http.ResponseWriter, r *http.Request) {
noteLabel = "Additional note" noteLabel = "Additional note"
} }
var ssoURL, ssoSecret string
app.db.QueryRow(`SELECT value FROM config WHERE key = 'discourse_sso_url'`).Scan(&ssoURL)
app.db.QueryRow(`SELECT value FROM config WHERE key = 'discourse_sso_secret'`).Scan(&ssoSecret)
maskedSSOSecret := ""
if ssoSecret != "" {
maskedSSOSecret = "***"
}
writeJSON(w, map[string]any{ writeJSON(w, map[string]any{
"smtp_host": cfg.Host, "smtp_host": cfg.Host,
"smtp_port": cfg.Port, "smtp_port": cfg.Port,
@ -38,6 +46,8 @@ func (app *App) handleGetSettings(w http.ResponseWriter, r *http.Request) {
"volunteer_note_label": noteLabel, "volunteer_note_label": noteLabel,
"volunteer_note_required": noteRequired == "true", "volunteer_note_required": noteRequired == "true",
"shift_signups_open": signupsOpen == "true", "shift_signups_open": signupsOpen == "true",
"discourse_sso_url": ssoURL,
"discourse_sso_secret": maskedSSOSecret,
}) })
} }
@ -49,7 +59,7 @@ func (app *App) handleUpdateSettings(w http.ResponseWriter, r *http.Request) {
} }
keys := []string{"smtp_host", "smtp_port", "smtp_user", "smtp_password", "smtp_from", "smtp_from_name", "base_url", keys := []string{"smtp_host", "smtp_port", "smtp_user", "smtp_password", "smtp_from", "smtp_from_name", "base_url",
"volunteer_note_label", "volunteer_note_required"} "volunteer_note_label", "volunteer_note_required", "discourse_sso_url", "discourse_sso_secret"}
for _, k := range keys { for _, k := range keys {
v, ok := body[k] v, ok := body[k]
if !ok { if !ok {
@ -58,7 +68,7 @@ func (app *App) handleUpdateSettings(w http.ResponseWriter, r *http.Request) {
var val string var val string
switch vv := v.(type) { switch vv := v.(type) {
case string: case string:
if k == "smtp_password" && (vv == "" || vv == "***") { if (k == "smtp_password" || k == "discourse_sso_secret") && (vv == "" || vv == "***") {
continue continue
} }
val = vv val = vv

190
handle_sso.go Normal file
View file

@ -0,0 +1,190 @@
package main
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"fmt"
"net/http"
"net/url"
"strings"
)
func (app *App) getSSOConfig() (ssoURL, ssoSecret string) {
app.db.QueryRow(`SELECT value FROM config WHERE key = 'discourse_sso_url'`).Scan(&ssoURL)
app.db.QueryRow(`SELECT value FROM config WHERE key = 'discourse_sso_secret'`).Scan(&ssoSecret)
return
}
func (app *App) handleSSOEnabled(w http.ResponseWriter, r *http.Request) {
ssoURL, ssoSecret := app.getSSOConfig()
writeJSON(w, map[string]bool{"enabled": ssoURL != "" && ssoSecret != ""})
}
func (app *App) getBaseURL() string {
if app.baseURL != "" {
return app.baseURL
}
var u string
app.db.QueryRow(`SELECT value FROM config WHERE key = 'base_url'`).Scan(&u)
return u
}
func (app *App) handleSSOInit(w http.ResponseWriter, r *http.Request) {
ssoURL, ssoSecret := app.getSSOConfig()
if ssoURL == "" || ssoSecret == "" {
writeError(w, "SSO not configured", http.StatusNotFound)
return
}
baseURL := app.getBaseURL()
if baseURL == "" {
writeError(w, "base_url must be configured for SSO", http.StatusBadRequest)
return
}
b := make([]byte, 32)
rand.Read(b)
nonce := hex.EncodeToString(b)
app.cleanExpiredNonces()
if err := app.createSSONonce(nonce); err != nil {
writeError(w, "internal error", http.StatusInternalServerError)
return
}
returnURL := strings.TrimRight(baseURL, "/") + "/api/sso/callback"
payload := fmt.Sprintf("nonce=%s&return_sso_url=%s", url.QueryEscape(nonce), url.QueryEscape(returnURL))
encoded := base64.StdEncoding.EncodeToString([]byte(payload))
mac := hmac.New(sha256.New, []byte(ssoSecret))
mac.Write([]byte(encoded))
sig := hex.EncodeToString(mac.Sum(nil))
redirect := fmt.Sprintf("%s/session/sso_provider?sso=%s&sig=%s",
strings.TrimRight(ssoURL, "/"), url.QueryEscape(encoded), url.QueryEscape(sig))
writeJSON(w, map[string]string{"redirect_url": redirect})
}
func (app *App) handleSSOCallback(w http.ResponseWriter, r *http.Request) {
baseURL := app.getBaseURL()
ssoRedirectError := func(msg string) {
if baseURL != "" {
http.Redirect(w, r, strings.TrimRight(baseURL, "/")+"/#sso_error="+url.QueryEscape(msg), http.StatusFound)
} else {
writeError(w, msg, http.StatusBadRequest)
}
}
_, ssoSecret := app.getSSOConfig()
if ssoSecret == "" {
ssoRedirectError("SSO not configured")
return
}
ssoParam := r.URL.Query().Get("sso")
sigParam := r.URL.Query().Get("sig")
if ssoParam == "" || sigParam == "" {
ssoRedirectError("Invalid SSO response")
return
}
mac := hmac.New(sha256.New, []byte(ssoSecret))
mac.Write([]byte(ssoParam))
expectedSig := hex.EncodeToString(mac.Sum(nil))
if !hmac.Equal([]byte(expectedSig), []byte(sigParam)) {
ssoRedirectError("Invalid SSO signature")
return
}
decoded, err := base64.StdEncoding.DecodeString(ssoParam)
if err != nil {
ssoRedirectError("Invalid SSO payload")
return
}
vals, err := url.ParseQuery(string(decoded))
if err != nil {
ssoRedirectError("Invalid SSO payload")
return
}
nonce := vals.Get("nonce")
valid, err := app.consumeSSONonce(nonce)
if err != nil || !valid {
ssoRedirectError("SSO session expired. Please try again.")
return
}
email := strings.ToLower(vals.Get("email"))
if email == "" {
ssoRedirectError("No email in SSO response")
return
}
name := vals.Get("name")
if name == "" {
name = vals.Get("username")
}
user, _, err := app.getLoginParticipant(email)
if err != nil {
ssoRedirectError("Login failed. Please try again.")
return
}
if user == nil {
p, err := app.getParticipantByEmail(email)
if err != nil {
ssoRedirectError("Login failed. Please try again.")
return
}
if p != nil {
if _, err := app.db.Exec(
`UPDATE participants SET login_enabled = 1, updated_at = ? WHERE id = ?`,
now(), p.ID,
); err != nil {
ssoRedirectError("Login failed. Please try again.")
return
}
user, err = app.getUser(p.ID)
if err != nil {
ssoRedirectError("Login failed. Please try again.")
return
}
}
}
if user == nil {
if name == "" {
name = strings.Split(email, "@")[0]
}
res, err := app.db.Exec(
`INSERT INTO participants (email, preferred_name, login_enabled, updated_at) VALUES (?, ?, 1, ?)`,
email, name, now(),
)
if err != nil {
ssoRedirectError("Login failed. Please try again.")
return
}
id, _ := res.LastInsertId()
user, err = app.getUser(int(id))
if err != nil || user == nil {
ssoRedirectError("Login failed. Please try again.")
return
}
}
token, err := app.signToken(user)
if err != nil {
ssoRedirectError("Login failed. Please try again.")
return
}
http.Redirect(w, r, strings.TrimRight(baseURL, "/")+"/#sso_token="+url.QueryEscape(token), http.StatusFound)
}

View file

@ -164,6 +164,9 @@ func (app *App) registerRoutes(mux *http.ServeMux) {
mux.HandleFunc("POST /api/settings/shift-signups", auth(app.handleToggleShiftSignups, "admin", "staffing")) mux.HandleFunc("POST /api/settings/shift-signups", auth(app.handleToggleShiftSignups, "admin", "staffing"))
// Public endpoints — no JWT required. // Public endpoints — no JWT required.
mux.HandleFunc("GET /api/public/sso-enabled", app.handleSSOEnabled)
mux.HandleFunc("GET /api/sso/init", app.handleSSOInit)
mux.HandleFunc("GET /api/sso/callback", app.handleSSOCallback)
mux.HandleFunc("GET /api/public/signup-config", app.handlePublicSignupConfig) mux.HandleFunc("GET /api/public/signup-config", app.handlePublicSignupConfig)
mux.HandleFunc("POST /api/public/signup", app.handlePublicSignup) mux.HandleFunc("POST /api/public/signup", app.handlePublicSignup)
mux.HandleFunc("POST /api/public/confirm", app.handleConfirmEmail) mux.HandleFunc("POST /api/public/confirm", app.handleConfirmEmail)