Compare commits
No commits in common. "trunk" and "0.11.0" have entirely different histories.
15 changed files with 94 additions and 452 deletions
26
db.go
26
db.go
|
|
@ -140,11 +140,6 @@ 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
|
||||||
}
|
}
|
||||||
|
|
@ -1355,27 +1350,6 @@ 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 {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
<script>
|
<script>
|
||||||
import { onMount } from 'svelte'
|
import { onMount } from 'svelte'
|
||||||
import { getSession, saveSession, clearSession } from './db.js'
|
import { getSession, 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,7 +25,6 @@
|
||||||
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] ?? '')
|
||||||
|
|
@ -37,7 +36,6 @@
|
||||||
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() {
|
||||||
|
|
@ -56,32 +54,7 @@
|
||||||
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()
|
||||||
|
|
@ -129,7 +102,7 @@
|
||||||
{:else if isConfirmEmail}
|
{:else if isConfirmEmail}
|
||||||
<ConfirmEmail />
|
<ConfirmEmail />
|
||||||
{:else if !session}
|
{:else if !session}
|
||||||
<Login onlogin={onLogin} error={ssoError} />
|
<Login onlogin={onLogin} />
|
||||||
{: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} />
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { db, clearSession } from './db.js'
|
import { db } 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 clearSession()
|
await db.session.clear()
|
||||||
window.location.pathname = '/login'
|
window.location.pathname = '/login'
|
||||||
throw new Error('unauthorized')
|
throw new Error('unauthorized')
|
||||||
}
|
}
|
||||||
|
|
@ -118,10 +118,6 @@ 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) }),
|
||||||
|
|
|
||||||
|
|
@ -66,9 +66,6 @@ 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; }
|
||||||
|
|
@ -106,15 +103,8 @@ 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; }
|
||||||
|
|
@ -139,7 +129,6 @@ 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; }
|
||||||
|
|
@ -245,7 +234,6 @@ 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 — 16px prevents iOS auto-zoom on focus */
|
/* Forms */
|
||||||
input, select, textarea { font-size: 16px; }
|
.form-grid { grid-template-columns: 1fr !important; }
|
||||||
.form-grid, .form-grid-3 { grid-template-columns: 1fr !important; }
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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">{r}</span>{/each}
|
· {#each roles as r}<span class="badge badge-role" style="margin-right:0.25rem">{r}</span>{/each}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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-3">
|
<div class="form-grid" style="display:grid;grid-template-columns:1fr 1fr auto;gap:1rem;align-items:end">
|
||||||
<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} class="color-input" />
|
<input id="d-color" type="color" bind:value={newColor} style="width:60px;padding:0.2rem;height:2.3rem;cursor:pointer" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="actions" style="margin-top:1rem">
|
<div class="actions" style="margin-top:1rem">
|
||||||
|
|
@ -191,7 +191,6 @@
|
||||||
</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%; }
|
||||||
|
|
|
||||||
|
|
@ -1,25 +1,13 @@
|
||||||
<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, error: externalError = '' } = $props()
|
let { onlogin } = $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()
|
||||||
|
|
@ -35,18 +23,6 @@
|
||||||
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">
|
||||||
|
|
@ -69,28 +45,5 @@
|
||||||
{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>
|
|
||||||
|
|
|
||||||
|
|
@ -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">
|
<div class="form-grid" style="display:grid;grid-template-columns:1fr 1fr;gap:1rem">
|
||||||
<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" />
|
||||||
|
|
|
||||||
|
|
@ -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 class="form-grid">
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem">
|
||||||
<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,11 +380,10 @@
|
||||||
<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">⚠ conflict</span>
|
<span class="badge badge-lead" style="margin-left:0.3rem">⚠ 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"
|
||||||
|
|
@ -393,7 +392,6 @@
|
||||||
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 -->
|
||||||
|
|
@ -408,14 +406,13 @@
|
||||||
{#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}
|
||||||
{#if canManage}<button class="board-vol-remove" onclick={() => unassign(shift.id, volunteer.id)} title="Remove">×</button>{/if}
|
<button class="board-vol-remove" onclick={() => unassign(shift.id, volunteer.id)} title="Remove">×</button>
|
||||||
</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">
|
||||||
|
|
@ -439,7 +436,6 @@
|
||||||
<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}
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,6 @@
|
||||||
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('')
|
||||||
|
|
||||||
|
|
@ -27,8 +26,6 @@
|
||||||
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)
|
||||||
|
|
||||||
|
|
@ -52,8 +49,6 @@
|
||||||
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
|
||||||
|
|
@ -94,17 +89,14 @@
|
||||||
smtp_host: smtpHost,
|
smtp_host: smtpHost,
|
||||||
smtp_port: smtpPort,
|
smtp_port: smtpPort,
|
||||||
smtp_user: smtpUser,
|
smtp_user: smtpUser,
|
||||||
smtp_password: smtpPassword,
|
smtp_password: smtpPassword, // empty = keep existing
|
||||||
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
|
||||||
|
|
@ -131,9 +123,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
||||||
|
|
@ -141,8 +131,6 @@
|
||||||
success = `Deleted ${result.deleted} ${label}.`
|
success = `Deleted ${result.deleted} ${label}.`
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error = err.message
|
error = err.message
|
||||||
} finally {
|
|
||||||
resetting = false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -178,14 +166,14 @@
|
||||||
<div class="text-muted">Loading…</div>
|
<div class="text-muted">Loading…</div>
|
||||||
{:else}
|
{:else}
|
||||||
<form onsubmit={saveEvent}>
|
<form onsubmit={saveEvent}>
|
||||||
<div class="card">
|
<div class="card" style="margin-bottom:1.5rem">
|
||||||
<h2 class="card-title">Event</h2>
|
<h2 style="font-size:0.95rem;font-weight:700;margin-bottom:1rem">Event</h2>
|
||||||
<div class="form-grid">
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem">
|
||||||
<div class="form-group full">
|
<div class="form-group" style="grid-column:1/-1">
|
||||||
<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 full">
|
<div class="form-group" style="grid-column:1/-1">
|
||||||
<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>
|
||||||
|
|
@ -197,7 +185,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 full">
|
<div class="form-group" style="grid-column:1/-1">
|
||||||
<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">
|
||||||
|
|
@ -216,11 +204,11 @@
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<form onsubmit={save}>
|
<form onsubmit={save}>
|
||||||
<div class="card">
|
<div class="card" style="margin-bottom:1.5rem">
|
||||||
<h2 class="card-title">SMTP Email</h2>
|
<h2 style="font-size:0.95rem;font-weight:700;margin-bottom:1rem">SMTP Email</h2>
|
||||||
|
|
||||||
<div class="form-grid">
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem">
|
||||||
<div class="form-group">
|
<div class="form-group" style="grid-column:1">
|
||||||
<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>
|
||||||
|
|
@ -248,27 +236,10 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="s-url">Base URL <span class="card-hint" style="font-weight:400">(for kiosk links in emails)</span></label>
|
<label for="s-url">Base URL <span class="text-muted" 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 > 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'}
|
||||||
|
|
@ -278,8 +249,8 @@
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<!-- Test email -->
|
<!-- Test email -->
|
||||||
<div class="card">
|
<div class="card" style="margin-bottom:1.5rem">
|
||||||
<h2 class="card-title">Test Email</h2>
|
<h2 style="font-size:0.95rem;font-weight:700;margin-bottom:1rem">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>
|
||||||
|
|
@ -292,24 +263,24 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Volunteer Signup -->
|
<!-- Volunteer Signup -->
|
||||||
<div class="card">
|
<div class="card" style="margin-bottom:1.5rem">
|
||||||
<h2 class="card-title">Volunteer Signup</h2>
|
<h2 style="font-size:0.95rem;font-weight:700;margin-bottom:1rem">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 class="checkbox-label">
|
<label style="display:flex;align-items:center;gap:0.5rem;font-size:0.875rem;cursor:pointer">
|
||||||
<input type="checkbox" bind:checked={noteRequired} />
|
<input type="checkbox" bind:checked={noteRequired} />
|
||||||
Note field is required
|
Note field is required
|
||||||
</label>
|
</label>
|
||||||
<p class="card-hint" style="margin-top:0.75rem">
|
<p class="text-muted" style="font-size:0.78rem;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">
|
<div class="card" style="margin-bottom:1.5rem">
|
||||||
<h2 class="card-title">Shift Signups</h2>
|
<h2 style="font-size:0.95rem;font-weight:700;margin-bottom:1rem">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>
|
||||||
|
|
@ -324,7 +295,7 @@
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{#if !shiftSignupsOpen}
|
{#if !shiftSignupsOpen}
|
||||||
<p class="card-hint" style="margin-top:0.75rem">
|
<p class="text-muted" style="font-size:0.78rem;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}
|
||||||
|
|
@ -332,24 +303,24 @@
|
||||||
|
|
||||||
<!-- Data Management -->
|
<!-- Data Management -->
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2 class="card-title" style="margin-bottom:0.5rem">Data Management</h2>
|
<h2 style="font-size:0.95rem;font-weight:700;margin-bottom:0.5rem">Data Management</h2>
|
||||||
<p class="card-hint" style="margin-bottom:1rem">
|
<p class="text-muted" style="font-size:0.78rem;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" disabled={resetting} onclick={() => resetModel('tickets', api.settings.resetTickets)}>
|
<button class="btn btn-danger" onclick={() => resetModel('tickets', api.settings.resetTickets)}>
|
||||||
Delete All Tickets
|
Delete All Tickets
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-danger" disabled={resetting} onclick={() => resetModel('volunteers', api.settings.resetVolunteers)}>
|
<button class="btn btn-danger" onclick={() => resetModel('volunteers', api.settings.resetVolunteers)}>
|
||||||
Delete All Volunteers
|
Delete All Volunteers
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-danger" disabled={resetting} onclick={() => resetModel('shifts', api.settings.resetShifts)}>
|
<button class="btn btn-danger" onclick={() => resetModel('shifts', api.settings.resetShifts)}>
|
||||||
Delete All Shifts
|
Delete All Shifts
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-danger" disabled={resetting} onclick={() => resetModel('departments', api.settings.resetDepartments)}>
|
<button class="btn btn-danger" onclick={() => resetModel('departments', api.settings.resetDepartments)}>
|
||||||
Delete All Departments
|
Delete All Departments
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-danger" disabled={resetting} onclick={() => resetModel('volunteer shift assignments', api.settings.resetVolunteerShifts)}>
|
<button class="btn btn-danger" onclick={() => resetModel('volunteer shift assignments', api.settings.resetVolunteerShifts)}>
|
||||||
Delete All Shift Assignments
|
Delete All Shift Assignments
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -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-3">
|
<div class="form-grid" style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:1rem">
|
||||||
<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 class="checkbox-label">
|
<label style="display:flex;align-items:center;gap:0.35rem;cursor:pointer;font-size:0.875rem;color:var(--c-text)">
|
||||||
<input type="checkbox"
|
<input type="checkbox" style="width:auto"
|
||||||
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 class="checkbox-label">
|
<label style="display:flex;align-items:center;gap:0.35rem;cursor:pointer;font-size:0.875rem;color:var(--c-text)">
|
||||||
<input type="checkbox"
|
<input type="checkbox" style="width:auto"
|
||||||
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 class="checkbox-label-sm">
|
<label style="display:flex;align-items:center;gap:0.3rem;cursor:pointer;font-size:0.82rem;color:var(--c-text)">
|
||||||
<input type="checkbox"
|
<input type="checkbox" style="width:auto"
|
||||||
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 class="checkbox-label-sm">
|
<label style="display:flex;align-items:center;gap:0.3rem;cursor:pointer;font-size:0.82rem;color:var(--c-text)">
|
||||||
<input type="checkbox"
|
<input type="checkbox" style="width:auto"
|
||||||
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">you</span>
|
<span class="badge badge-role" style="margin-left:0.4rem">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">{roleLabel(r)}</span>{/each}</td>
|
<td>{#each u.roles ?? [] as r}<span class="badge badge-role" style="margin-right:0.25rem">{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">
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,6 @@
|
||||||
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)) }
|
||||||
|
|
@ -77,15 +76,11 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -186,7 +181,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">
|
<div class="form-grid" style="display:grid;grid-template-columns:1fr 1fr;gap:1rem">
|
||||||
<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" />
|
||||||
|
|
@ -214,8 +209,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 class="checkbox-label">
|
<label style="display:flex;align-items:center;gap:0.5rem;cursor:pointer">
|
||||||
<input type="checkbox" bind:checked={newIsLead} />
|
<input type="checkbox" style="width:auto" bind:checked={newIsLead} />
|
||||||
Department lead
|
Department lead
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -286,8 +281,8 @@
|
||||||
</select>
|
</select>
|
||||||
</td>
|
</td>
|
||||||
<td class="td-edit-checks">
|
<td class="td-edit-checks">
|
||||||
<label class="checkbox-label-sm" style="white-space:nowrap">
|
<label style="display:flex;align-items:center;gap:0.4rem;cursor:pointer;white-space:nowrap">
|
||||||
<input type="checkbox" bind:checked={editIsLead} /> Co-Lead
|
<input type="checkbox" style="width:auto" bind:checked={editIsLead} /> Co-Lead
|
||||||
</label>
|
</label>
|
||||||
</td>
|
</td>
|
||||||
<td class="td-edit-note">
|
<td class="td-edit-note">
|
||||||
|
|
@ -306,12 +301,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">Co-Lead</span>
|
<span class="badge badge-lead" style="margin-left:0.4rem">Co-Lead</span>
|
||||||
{/if}
|
{/if}
|
||||||
{#if !v.participant_id}
|
{#if !v.participant_id}
|
||||||
<span class="badge badge-unchecked" title="Not linked to a participant">No ticket</span>
|
<span class="badge badge-unchecked" style="margin-left:0.4rem" 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" title="No ticket on file">No ticket</span>
|
<span class="badge badge-partial" style="margin-left:0.4rem" 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>
|
||||||
|
|
@ -354,7 +349,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)} disabled={confirmingID === v.id}>Confirm</button>
|
<button class="btn btn-primary btn-sm" onclick={() => confirmVolunteer(v)}>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>
|
||||||
|
|
|
||||||
|
|
@ -27,14 +27,6 @@ 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,
|
||||||
|
|
@ -46,8 +38,6 @@ 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,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -59,7 +49,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", "discourse_sso_url", "discourse_sso_secret"}
|
"volunteer_note_label", "volunteer_note_required"}
|
||||||
for _, k := range keys {
|
for _, k := range keys {
|
||||||
v, ok := body[k]
|
v, ok := body[k]
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|
@ -68,7 +58,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" || k == "discourse_sso_secret") && (vv == "" || vv == "***") {
|
if k == "smtp_password" && (vv == "" || vv == "***") {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
val = vv
|
val = vv
|
||||||
|
|
|
||||||
190
handle_sso.go
190
handle_sso.go
|
|
@ -1,190 +0,0 @@
|
||||||
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)
|
|
||||||
}
|
|
||||||
3
main.go
3
main.go
|
|
@ -164,9 +164,6 @@ 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)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue