2026-03-03 11:27:07 -06:00
|
|
|
<script>
|
|
|
|
|
import { liveQuery } from 'dexie'
|
|
|
|
|
import { db } from '../db.js'
|
|
|
|
|
import { api } from '../api.js'
|
|
|
|
|
|
|
|
|
|
let { session } = $props()
|
|
|
|
|
|
|
|
|
|
let users = $state([])
|
|
|
|
|
let loadError = $state('')
|
|
|
|
|
let error = $state('')
|
|
|
|
|
let loading = $state(true)
|
|
|
|
|
|
|
|
|
|
let showAdd = $state(false)
|
|
|
|
|
let adding = $state(false)
|
|
|
|
|
let newUsername = $state('')
|
|
|
|
|
let newPassword = $state('')
|
|
|
|
|
let newRole = $state('gate')
|
|
|
|
|
let newDeptIDs = $state([])
|
|
|
|
|
|
|
|
|
|
let editID = $state(null)
|
|
|
|
|
let editRole = $state('')
|
|
|
|
|
let editDeptIDs = $state([])
|
|
|
|
|
let editPassword = $state('')
|
|
|
|
|
let saving = $state(false)
|
|
|
|
|
|
|
|
|
|
const allDepts = liveQuery(() =>
|
|
|
|
|
db.departments.filter(d => !d.deleted_at).toArray()
|
|
|
|
|
.then(arr => arr.sort((a, b) => a.name.localeCompare(b.name)))
|
|
|
|
|
)
|
|
|
|
|
|
2026-03-04 12:00:36 -06:00
|
|
|
const roles = ['admin', 'ticketing', 'staffing', 'colead', 'gatekeeper']
|
2026-03-03 11:27:07 -06:00
|
|
|
|
|
|
|
|
const me = $derived(session?.user?.id)
|
|
|
|
|
|
|
|
|
|
async function loadUsers() {
|
|
|
|
|
loading = true
|
|
|
|
|
try {
|
|
|
|
|
users = await api.users.list()
|
|
|
|
|
} catch (err) {
|
|
|
|
|
loadError = err.message
|
|
|
|
|
} finally {
|
|
|
|
|
loading = false
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$effect(() => { loadUsers() })
|
|
|
|
|
|
|
|
|
|
async function addUser(e) {
|
|
|
|
|
e.preventDefault()
|
|
|
|
|
adding = true
|
|
|
|
|
error = ''
|
|
|
|
|
try {
|
|
|
|
|
const u = await api.users.create({
|
|
|
|
|
username: newUsername,
|
|
|
|
|
password: newPassword,
|
|
|
|
|
role: newRole,
|
|
|
|
|
department_ids: newDeptIDs,
|
|
|
|
|
})
|
|
|
|
|
users = [...users, u]
|
|
|
|
|
showAdd = false
|
|
|
|
|
newUsername = newPassword = ''
|
|
|
|
|
newRole = 'gate'
|
|
|
|
|
newDeptIDs = []
|
|
|
|
|
} catch (err) {
|
|
|
|
|
error = err.message
|
|
|
|
|
} finally {
|
|
|
|
|
adding = false
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function startEdit(u) {
|
|
|
|
|
editID = u.id
|
|
|
|
|
editRole = u.role
|
|
|
|
|
editDeptIDs = [...(u.department_ids || [])]
|
|
|
|
|
editPassword = ''
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function cancelEdit() {
|
|
|
|
|
editID = null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function saveUser(u) {
|
|
|
|
|
saving = true
|
|
|
|
|
error = ''
|
|
|
|
|
try {
|
|
|
|
|
const payload = { role: editRole, department_ids: editDeptIDs }
|
|
|
|
|
if (editPassword) payload.password = editPassword
|
|
|
|
|
const updated = await api.users.update(u.id, payload)
|
|
|
|
|
users = users.map(x => x.id === u.id ? updated : x)
|
|
|
|
|
editID = null
|
|
|
|
|
} catch (err) {
|
|
|
|
|
error = err.message
|
|
|
|
|
} finally {
|
|
|
|
|
saving = false
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function deleteUser(u) {
|
|
|
|
|
if (!confirm(`Delete user "${u.username}"? This cannot be undone.`)) return
|
|
|
|
|
try {
|
|
|
|
|
await api.users.delete(u.id)
|
|
|
|
|
users = users.filter(x => x.id !== u.id)
|
|
|
|
|
} catch (err) {
|
|
|
|
|
error = err.message
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function toggleDept(id, list) {
|
|
|
|
|
const idx = list.indexOf(id)
|
|
|
|
|
if (idx === -1) return [...list, id]
|
|
|
|
|
return list.filter(x => x !== id)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function deptNamesFor(ids) {
|
|
|
|
|
const depts = $allDepts ?? []
|
|
|
|
|
return ids.map(id => depts.find(d => d.id === id)?.name).filter(Boolean).join(', ') || '—'
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function roleLabel(r) {
|
2026-03-04 20:52:12 -06:00
|
|
|
return { admin: 'Admin', ticketing: 'Ticketing', staffing: 'Staffing', colead: 'Colead', gatekeeper: 'Gatekeeper' }[r] || r
|
2026-03-03 11:27:07 -06:00
|
|
|
}
|
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
<div class="page">
|
|
|
|
|
<div class="page-header">
|
|
|
|
|
<h1 class="page-title">Users</h1>
|
|
|
|
|
<div class="actions">
|
|
|
|
|
<button class="btn btn-primary btn-sm" onclick={() => showAdd = !showAdd}>+ Add user</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
2026-03-04 20:52:12 -06:00
|
|
|
<p class="text-muted" style="font-size:0.82rem;margin-bottom:1.5rem;line-height:1.6">
|
|
|
|
|
<strong style="color:var(--c-text)">Roles:</strong>
|
|
|
|
|
admin — full access ·
|
|
|
|
|
ticketing — participants, tickets, import ·
|
|
|
|
|
staffing — volunteers, shifts, departments ·
|
|
|
|
|
colead — manage assigned departments only ·
|
|
|
|
|
gatekeeper — check-in only
|
|
|
|
|
</p>
|
|
|
|
|
|
2026-03-03 11:27:07 -06:00
|
|
|
{#if loadError}
|
|
|
|
|
<div class="alert alert-error">{loadError}</div>
|
|
|
|
|
{/if}
|
|
|
|
|
{#if error}
|
|
|
|
|
<div class="alert alert-error">{error}</div>
|
|
|
|
|
{/if}
|
|
|
|
|
|
|
|
|
|
{#if showAdd}
|
|
|
|
|
<div class="card" style="margin-bottom:1.5rem">
|
|
|
|
|
<form onsubmit={addUser}>
|
|
|
|
|
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:1rem">
|
|
|
|
|
<div class="form-group">
|
|
|
|
|
<label for="u-username">Username *</label>
|
|
|
|
|
<input id="u-username" bind:value={newUsername} required placeholder="username" autocomplete="off" />
|
|
|
|
|
</div>
|
|
|
|
|
<div class="form-group">
|
|
|
|
|
<label for="u-password">Password *</label>
|
|
|
|
|
<input id="u-password" type="password" bind:value={newPassword} required autocomplete="new-password" />
|
|
|
|
|
</div>
|
|
|
|
|
<div class="form-group">
|
|
|
|
|
<label for="u-role">Role *</label>
|
|
|
|
|
<select id="u-role" bind:value={newRole}>
|
|
|
|
|
{#each roles as r}
|
|
|
|
|
<option value={r}>{roleLabel(r)}</option>
|
|
|
|
|
{/each}
|
|
|
|
|
</select>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
{#if ($allDepts ?? []).length > 0}
|
|
|
|
|
<div class="form-group">
|
|
|
|
|
<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">
|
|
|
|
|
{#each $allDepts ?? [] as d}
|
|
|
|
|
<label style="display:flex;align-items:center;gap:0.35rem;cursor:pointer;font-size:0.875rem;color:var(--c-text)">
|
|
|
|
|
<input type="checkbox" style="width:auto"
|
|
|
|
|
checked={newDeptIDs.includes(d.id)}
|
|
|
|
|
onchange={() => newDeptIDs = toggleDept(d.id, newDeptIDs)} />
|
|
|
|
|
<span class="dept-dot" style="background:{d.color}"></span>
|
|
|
|
|
{d.name}
|
|
|
|
|
</label>
|
|
|
|
|
{/each}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
{/if}
|
|
|
|
|
<div class="actions">
|
|
|
|
|
<button type="submit" class="btn btn-primary" disabled={adding}>
|
|
|
|
|
{adding ? 'Adding…' : 'Add user'}
|
|
|
|
|
</button>
|
|
|
|
|
<button type="button" class="btn btn-ghost" onclick={() => showAdd = false}>Cancel</button>
|
|
|
|
|
</div>
|
|
|
|
|
</form>
|
|
|
|
|
</div>
|
|
|
|
|
{/if}
|
|
|
|
|
|
|
|
|
|
{#if loading}
|
|
|
|
|
<div class="text-muted" style="padding:2rem 0">Loading…</div>
|
|
|
|
|
{:else if users.length === 0}
|
|
|
|
|
<div class="empty">
|
2026-03-04 20:52:12 -06:00
|
|
|
<strong>No additional users</strong>
|
|
|
|
|
<p>The admin account was created at setup. Add users above to delegate access.</p>
|
2026-03-03 11:27:07 -06:00
|
|
|
</div>
|
|
|
|
|
{:else}
|
|
|
|
|
<div class="table-wrap">
|
|
|
|
|
<table>
|
|
|
|
|
<thead>
|
|
|
|
|
<tr>
|
|
|
|
|
<th>Username</th>
|
|
|
|
|
<th>Role</th>
|
|
|
|
|
<th>Departments</th>
|
|
|
|
|
<th></th>
|
|
|
|
|
</tr>
|
|
|
|
|
</thead>
|
|
|
|
|
<tbody>
|
|
|
|
|
{#each users as u (u.id)}
|
|
|
|
|
{#if editID === u.id}
|
|
|
|
|
<tr>
|
|
|
|
|
<td><strong>{u.username}</strong> {#if u.id === me}<span class="badge badge-role">you</span>{/if}</td>
|
|
|
|
|
<td>
|
|
|
|
|
<select bind:value={editRole} style="width:auto;margin:0">
|
|
|
|
|
{#each roles as r}
|
|
|
|
|
<option value={r}>{roleLabel(r)}</option>
|
|
|
|
|
{/each}
|
|
|
|
|
</select>
|
|
|
|
|
</td>
|
|
|
|
|
<td>
|
|
|
|
|
{#if ($allDepts ?? []).length > 0}
|
|
|
|
|
<div style="display:flex;flex-wrap:wrap;gap:0.4rem">
|
|
|
|
|
{#each $allDepts ?? [] as d}
|
|
|
|
|
<label style="display:flex;align-items:center;gap:0.3rem;cursor:pointer;font-size:0.82rem;color:var(--c-text)">
|
|
|
|
|
<input type="checkbox" style="width:auto"
|
|
|
|
|
checked={editDeptIDs.includes(d.id)}
|
|
|
|
|
onchange={() => editDeptIDs = toggleDept(d.id, editDeptIDs)} />
|
|
|
|
|
{d.name}
|
|
|
|
|
</label>
|
|
|
|
|
{/each}
|
|
|
|
|
</div>
|
|
|
|
|
{/if}
|
|
|
|
|
<input type="password" bind:value={editPassword}
|
|
|
|
|
placeholder="New password (leave blank to keep)"
|
|
|
|
|
style="margin-top:0.5rem" autocomplete="new-password" />
|
|
|
|
|
</td>
|
|
|
|
|
<td>
|
|
|
|
|
<div class="actions">
|
|
|
|
|
<button class="btn btn-primary btn-sm" onclick={() => saveUser(u)} disabled={saving}>
|
|
|
|
|
{saving ? '…' : 'Save'}
|
|
|
|
|
</button>
|
|
|
|
|
<button class="btn btn-ghost btn-sm" onclick={cancelEdit}>Cancel</button>
|
|
|
|
|
</div>
|
|
|
|
|
</td>
|
|
|
|
|
</tr>
|
|
|
|
|
{:else}
|
|
|
|
|
<tr>
|
|
|
|
|
<td>
|
|
|
|
|
<strong>{u.username}</strong>
|
|
|
|
|
{#if u.id === me}
|
|
|
|
|
<span class="badge badge-role" style="margin-left:0.4rem">you</span>
|
|
|
|
|
{/if}
|
|
|
|
|
</td>
|
|
|
|
|
<td><span class="badge badge-role">{roleLabel(u.role)}</span></td>
|
|
|
|
|
<td class="text-muted">{deptNamesFor(u.department_ids || [])}</td>
|
|
|
|
|
<td>
|
|
|
|
|
<div class="actions">
|
|
|
|
|
<button class="btn btn-ghost btn-sm" onclick={() => startEdit(u)}>Edit</button>
|
|
|
|
|
{#if u.id !== me}
|
|
|
|
|
<button class="btn btn-danger btn-sm" onclick={() => deleteUser(u)}>Delete</button>
|
|
|
|
|
{/if}
|
|
|
|
|
</div>
|
|
|
|
|
</td>
|
|
|
|
|
</tr>
|
|
|
|
|
{/if}
|
|
|
|
|
{/each}
|
|
|
|
|
</tbody>
|
|
|
|
|
</table>
|
|
|
|
|
</div>
|
|
|
|
|
{/if}
|
|
|
|
|
</div>
|