Compare commits

..

No commits in common. "trunk" and "sso" have entirely different histories.
trunk ... sso

11 changed files with 82 additions and 108 deletions

View file

@ -37,7 +37,6 @@
history.pushState(null, '', path)
route = path
mobileNavOpen = false
window.scrollTo(0, 0)
}
async function checkVersion() {

View file

@ -1,4 +1,4 @@
import { db, clearSession } from './db.js'
import { db } from './db.js'
async function getToken() {
const session = await db.session.get(1)
@ -17,7 +17,7 @@ export async function apiFetch(path, options = {}) {
const res = await fetch(path, { ...options, headers })
if (res.status === 401) {
await clearSession()
await db.session.clear()
window.location.pathname = '/login'
throw new Error('unauthorized')
}

View file

@ -66,9 +66,6 @@ a:hover { color: var(--c-accent-h); }
/* Cards */
.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 { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 1rem; margin-bottom: 1.5rem; }
@ -107,12 +104,8 @@ input, select, textarea {
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::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); }
@ -139,7 +132,6 @@ tr:hover td { background: rgba(255,255,255,0.02); }
font-size: 0.72rem; font-weight: 600;
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-confirmed { background: rgba(99,102,241,0.15); color: var(--c-accent-h); }
.badge-registered { background: rgba(14,165,233,0.15); color: #0ea5e9; }
@ -245,7 +237,6 @@ tr:hover td { background: rgba(255,255,255,0.02); }
td { display: inline; padding: 0; border: none; }
td:empty { display: none; }
/* Forms — 16px prevents iOS auto-zoom on focus */
input, select, textarea { font-size: 16px; }
.form-grid, .form-grid-3 { grid-template-columns: 1fr !important; }
/* Forms */
.form-grid { grid-template-columns: 1fr !important; }
}

View file

@ -160,7 +160,7 @@
<p class="text-muted" style="font-size:0.85rem;margin-top:2rem">
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>
</div>

View file

@ -101,7 +101,7 @@
{#if showAdd && canCreate}
<div class="card" style="margin-bottom:1.5rem">
<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">
<label for="d-name">Name *</label>
<input id="d-name" bind:value={newName} required placeholder="e.g. Security" />
@ -112,7 +112,7 @@
</div>
<div class="form-group" style="margin-bottom:0">
<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 class="actions" style="margin-top:1rem">
@ -191,7 +191,6 @@
</div>
<style>
.color-input { width: 60px; padding: 0.2rem; height: 2.3rem; cursor: pointer; }
@media (max-width: 640px) {
.td-name { width: 100%; }
.td-desc { width: 100%; }

View file

@ -3,13 +3,11 @@
import { api } from '../api.js'
import { saveSession } from '../db.js'
let { onlogin, error: externalError = '' } = $props()
let { onlogin, error: initialError = '' } = $props()
let email = $state('')
let password = $state('')
let error = $state('')
$effect(() => { if (externalError) error = externalError })
let error = $state(initialError)
let loading = $state(false)
let ssoEnabled = $state(false)
let ssoLoading = $state(false)

View file

@ -248,7 +248,7 @@
{#if showAdd && canManage}
<div class="card" style="margin-bottom:1.5rem">
<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">
<label for="p-name">Preferred Name</label>
<input id="p-name" bind:value={newName} placeholder="Preferred name" />

View file

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

View file

@ -7,7 +7,6 @@
let saving = $state(false)
let savingEvent = $state(false)
let testing = $state(false)
let resetting = $state(false)
let error = $state('')
let success = $state('')
@ -131,9 +130,7 @@
}
async function resetModel(label, fn) {
if (resetting) return
if (!confirm(`PERMANENTLY DELETE all ${label}? This cannot be undone.`)) return
resetting = true
error = ''
success = ''
try {
@ -141,8 +138,6 @@
success = `Deleted ${result.deleted} ${label}.`
} catch (err) {
error = err.message
} finally {
resetting = false
}
}
@ -178,14 +173,14 @@
<div class="text-muted">Loading…</div>
{:else}
<form onsubmit={saveEvent}>
<div class="card">
<h2 class="card-title">Event</h2>
<div class="form-grid">
<div class="form-group full">
<div class="card" style="margin-bottom:1.5rem">
<h2 style="font-size:0.95rem;font-weight:700;margin-bottom:1rem">Event</h2>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem">
<div class="form-group" style="grid-column:1/-1">
<label for="e-name">Event Name *</label>
<input id="e-name" bind:value={eventName} required placeholder="My Event 2026" />
</div>
<div class="form-group full">
<div class="form-group" style="grid-column:1/-1">
<label for="e-venue">Venue</label>
<input id="e-venue" bind:value={eventVenue} placeholder="Location name" />
</div>
@ -197,7 +192,7 @@
<label for="e-end">End Date *</label>
<input id="e-end" type="date" bind:value={eventEndDate} required />
</div>
<div class="form-group full">
<div class="form-group" style="grid-column:1/-1">
<label for="e-tz">Timezone</label>
<input id="e-tz" bind:value={eventTimezone} placeholder="America/Chicago" list="tz-list" />
<datalist id="tz-list">
@ -216,11 +211,11 @@
</form>
<form onsubmit={save}>
<div class="card">
<h2 class="card-title">SMTP Email</h2>
<div class="card" style="margin-bottom:1.5rem">
<h2 style="font-size:0.95rem;font-weight:700;margin-bottom:1rem">SMTP Email</h2>
<div class="form-grid">
<div class="form-group">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem">
<div class="form-group" style="grid-column:1">
<label for="s-host">SMTP Host</label>
<input id="s-host" bind:value={smtpHost} placeholder="smtp.fastmail.com" />
</div>
@ -248,21 +243,22 @@
</div>
<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" />
</div>
<h2 class="card-title" style="margin-top:1.5rem">Discourse SSO</h2>
<p class="card-hint" style="margin-bottom:1rem">
<!-- Discourse SSO -->
<h2 style="font-size:0.95rem;font-weight:700;margin:1.5rem 0 1rem">Discourse SSO</h2>
<p class="text-muted" style="font-size:0.78rem;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">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem">
<div class="form-group" style="grid-column:1/-1">
<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">
<div class="form-group" style="grid-column:1/-1">
<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" />
@ -278,8 +274,8 @@
</form>
<!-- Test email -->
<div class="card">
<h2 class="card-title">Test Email</h2>
<div class="card" style="margin-bottom:1.5rem">
<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 class="form-group" style="flex:1;margin-bottom:0">
<label for="s-test">Send to</label>
@ -292,8 +288,8 @@
</div>
<!-- Volunteer Signup -->
<div class="card">
<h2 class="card-title">Volunteer Signup</h2>
<div class="card" style="margin-bottom:1.5rem">
<h2 style="font-size:0.95rem;font-weight:700;margin-bottom:1rem">Volunteer Signup</h2>
<div class="form-group">
<label for="s-note-label">Note Field Label</label>
<input id="s-note-label" bind:value={noteLabel} placeholder="Additional note" />
@ -302,14 +298,14 @@
<input type="checkbox" bind:checked={noteRequired} />
Note field is required
</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>
</p>
</div>
<!-- Shift Signups -->
<div class="card">
<h2 class="card-title">Shift Signups</h2>
<div class="card" style="margin-bottom:1.5rem">
<h2 style="font-size:0.95rem;font-weight:700;margin-bottom:1rem">Shift Signups</h2>
<div style="display:flex;align-items:center;gap:1rem">
<span style="font-size:0.875rem">
Status: <strong>{shiftSignupsOpen ? 'Open' : 'Closed'}</strong>
@ -324,7 +320,7 @@
</button>
</div>
{#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.
</p>
{/if}
@ -332,24 +328,24 @@
<!-- Data Management -->
<div class="card">
<h2 class="card-title" style="margin-bottom:0.5rem">Data Management</h2>
<p class="card-hint" style="margin-bottom:1rem">
<h2 style="font-size:0.95rem;font-weight:700;margin-bottom:0.5rem">Data Management</h2>
<p class="text-muted" style="font-size:0.78rem;margin-bottom:1rem">
Permanently delete all records of a given type. This cannot be undone.
</p>
<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
</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
</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
</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
</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
</button>
</div>

View file

@ -149,7 +149,7 @@
{#if showAdd}
<div class="card" style="margin-bottom:1.5rem">
<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">
<label for="u-email">Email *</label>
<input id="u-email" type="email" bind:value={newEmail} required placeholder="email@example.com" autocomplete="off" />
@ -268,11 +268,11 @@
<td class="td-name">
<strong>{u.preferred_name || u.email}</strong>
{#if u.id === me}
<span class="badge badge-role">you</span>
<span class="badge badge-role" style="margin-left:0.4rem">you</span>
{/if}
<br><span class="text-muted" style="font-size:0.8rem">{u.email}</span>
</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="td-actions">
<div class="actions">

View file

@ -24,7 +24,6 @@
let editIsLead = $state(false)
let editNote = $state('')
let saving = $state(false)
let confirmingID = $state(null)
const roles = $derived(session?.user?.roles ?? [])
function hasRole(...allowed) { return roles.some(r => allowed.includes(r)) }
@ -77,15 +76,11 @@
}
async function confirmVolunteer(v) {
if (confirmingID) return
confirmingID = v.id
try {
const updated = await api.volunteers.confirm(v.id)
await db.volunteers.put(updated)
} catch (err) {
error = err.message
} finally {
confirmingID = null
}
}
@ -186,7 +181,7 @@
{#if showAdd && canManage}
<div class="card" style="margin-bottom:1.5rem">
<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">
<label for="v-name">Preferred Name *</label>
<input id="v-name" bind:value={newName} required placeholder="What they go by" />
@ -306,12 +301,12 @@
<td class="td-name">
<strong>{v.name}</strong>
{#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 !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)}
<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 participant?.ticket_name && participant.ticket_name !== v.name}
<div class="text-muted" style="font-size:0.78rem">Ticket: {participant.ticket_name}</div>
@ -354,7 +349,7 @@
{#if canManage}
<td class="td-actions">
{#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}
<button class="btn btn-ghost btn-sm" onclick={() => startEdit(v)}>Edit</button>
<button class="btn btn-danger btn-sm" onclick={() => deleteVolunteer(v)}>Delete</button>