Refactored user/volunteer/participant identity.
This commit is contained in:
parent
e640bf8bed
commit
883ebd584f
28 changed files with 450 additions and 265 deletions
|
|
@ -83,7 +83,8 @@
|
|||
}
|
||||
|
||||
const path = $derived(route || '/')
|
||||
const role = $derived(session?.user?.role ?? '')
|
||||
const roles = $derived(session?.user?.roles ?? [])
|
||||
function hasRole(...allowed) { return roles.some(r => allowed.includes(r)) }
|
||||
</script>
|
||||
|
||||
{#if updateAvailable}
|
||||
|
|
@ -103,8 +104,8 @@
|
|||
<ConfirmEmail />
|
||||
{:else if !session}
|
||||
<Login onlogin={onLogin} />
|
||||
{:else if role === 'gatekeeper'}
|
||||
<!-- Gate users get the full-screen GateKiosk instead of the standard layout -->
|
||||
{:else if roles.length === 1 && roles[0] === 'gatekeeper'}
|
||||
<!-- Gate-only users get the full-screen GateKiosk instead of the standard layout -->
|
||||
<GateKiosk {session} {onLogout} />
|
||||
{:else}
|
||||
<div class="layout">
|
||||
|
|
@ -121,7 +122,7 @@
|
|||
<span class="mobile-brand">Turn<span class="accent">pike</span></span>
|
||||
</header>
|
||||
{#if path === '/' || path === ''}
|
||||
{#if role === 'colead'}
|
||||
{#if roles.length === 1 && roles[0] === 'colead'}
|
||||
<ScheduleBoard {session} />
|
||||
{:else}
|
||||
<Dashboard {session} />
|
||||
|
|
|
|||
|
|
@ -48,8 +48,8 @@ async function kioskFetch(path, options = {}) {
|
|||
}
|
||||
|
||||
export const api = {
|
||||
login: (username, password) =>
|
||||
apiJSON('/api/login', { method: 'POST', body: JSON.stringify({ username, password }) }),
|
||||
login: (email, password) =>
|
||||
apiJSON('/api/login', { method: 'POST', body: JSON.stringify({ email, password }) }),
|
||||
logout: () => apiFetch('/api/logout', { method: 'POST' }),
|
||||
me: () => apiJSON('/api/me'),
|
||||
event: {
|
||||
|
|
|
|||
|
|
@ -64,11 +64,11 @@ describe('apiJSON', () => {
|
|||
describe('api methods', () => {
|
||||
it('login calls correct endpoint', async () => {
|
||||
const f = mockFetch({ token: 'tok', user: { id: 1 } })
|
||||
await api.login('admin', 'pass')
|
||||
await api.login('admin@example.com', 'pass')
|
||||
const [url, opts] = f.mock.calls[0]
|
||||
expect(url).toBe('/api/login')
|
||||
expect(opts.method).toBe('POST')
|
||||
expect(JSON.parse(opts.body)).toEqual({ username: 'admin', password: 'pass' })
|
||||
expect(JSON.parse(opts.body)).toEqual({ email: 'admin@example.com', password: 'pass' })
|
||||
})
|
||||
|
||||
it('participants.list calls correct endpoint', async () => {
|
||||
|
|
|
|||
|
|
@ -3,17 +3,18 @@
|
|||
|
||||
let { session, active, onLogout, navigate, open = false } = $props()
|
||||
|
||||
const role = $derived(session?.user?.role ?? '')
|
||||
const roles = $derived(session?.user?.roles ?? [])
|
||||
function hasRole(...allowed) { return roles.some(r => allowed.includes(r)) }
|
||||
|
||||
const iconProps = { size: 18, strokeWidth: 1.75 }
|
||||
|
||||
const links = $derived.by(() => {
|
||||
if (role === 'colead') return [
|
||||
if (!hasRole('admin') && hasRole('colead') && !hasRole('staffing')) return [
|
||||
{ href: '/', label: 'Schedule', icon: CalendarDays },
|
||||
{ href: '/volunteers', label: 'Volunteers', icon: Heart },
|
||||
{ href: '/departments', label: 'Departments', icon: Hexagon },
|
||||
]
|
||||
if (role === 'staffing') return [
|
||||
if (!hasRole('admin') && hasRole('staffing')) return [
|
||||
{ href: '/', label: 'Dashboard', icon: LayoutDashboard },
|
||||
{ href: '/schedule', label: 'Schedule', icon: CalendarDays },
|
||||
{ href: '/volunteers', label: 'Volunteers', icon: Heart },
|
||||
|
|
|
|||
|
|
@ -51,6 +51,8 @@ db.version(5).stores({
|
|||
participants: 'id, email, preferred_name, email_confirmed, updated_at, deleted_at',
|
||||
})
|
||||
|
||||
db.version(6).stores({}).upgrade(tx => tx.table('session').clear())
|
||||
|
||||
export async function getLastSync() {
|
||||
const m = await db.meta.get('last_sync')
|
||||
return m?.value ?? ''
|
||||
|
|
|
|||
|
|
@ -22,10 +22,10 @@ describe('session', () => {
|
|||
})
|
||||
|
||||
it('saves and retrieves session', async () => {
|
||||
await saveSession('tok123', { id: 1, username: 'admin', role: 'admin' })
|
||||
await saveSession('tok123', { id: 1, email: 'admin@example.com', roles: ['admin'] })
|
||||
const s = await getSession()
|
||||
expect(s.token).toBe('tok123')
|
||||
expect(s.user.username).toBe('admin')
|
||||
expect(s.user.email).toBe('admin@example.com')
|
||||
})
|
||||
|
||||
it('clears session and meta', async () => {
|
||||
|
|
|
|||
|
|
@ -4,11 +4,12 @@
|
|||
|
||||
let { session } = $props()
|
||||
|
||||
const role = $derived(session?.user?.role ?? '')
|
||||
const roles = $derived(session?.user?.roles ?? [])
|
||||
function hasRole(...allowed) { return roles.some(r => allowed.includes(r)) }
|
||||
const myDeptIDs = $derived(session?.user?.department_ids ?? [])
|
||||
const isTicketing = $derived(['admin', 'ticketing'].includes(role))
|
||||
const isStaffing = $derived(['admin', 'ticketing', 'staffing'].includes(role))
|
||||
const isColead = $derived(role === 'colead')
|
||||
const isAdmin = $derived(hasRole('admin'))
|
||||
const isStaffing = $derived(hasRole('admin', 'staffing'))
|
||||
const isColead = $derived(hasRole('colead'))
|
||||
|
||||
const event = liveQuery(() => db.event.get(1))
|
||||
const allTickets = liveQuery(() => db.tickets.toArray())
|
||||
|
|
@ -76,8 +77,8 @@
|
|||
</p>
|
||||
{/if}
|
||||
|
||||
<!-- Ticket check-in (admin/ticketing) -->
|
||||
{#if isTicketing}
|
||||
<!-- Ticket check-in (admin) -->
|
||||
{#if isAdmin}
|
||||
<h2 class="dash-section">Ticket Check-in</h2>
|
||||
<div class="stats">
|
||||
<div class="stat">
|
||||
|
|
@ -105,7 +106,7 @@
|
|||
{/if}
|
||||
{/if}
|
||||
|
||||
<!-- Volunteer stats (admin/ticketing/staffing/colead) -->
|
||||
<!-- Volunteer stats (admin/staffing/colead) -->
|
||||
{#if isStaffing || isColead}
|
||||
<h2 class="dash-section">{isColead ? 'My Volunteers' : 'Volunteers'}</h2>
|
||||
<div class="stats">
|
||||
|
|
@ -124,7 +125,7 @@
|
|||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Shift coverage (admin/ticketing/staffing/colead) -->
|
||||
<!-- Shift coverage (admin/staffing/colead) -->
|
||||
{#if isStaffing || isColead}
|
||||
<h2 class="dash-section">{isColead ? 'My Shifts' : 'Shift Coverage'}</h2>
|
||||
<div class="stats">
|
||||
|
|
@ -144,7 +145,7 @@
|
|||
{/if}
|
||||
|
||||
<!-- Quick actions -->
|
||||
{#if isTicketing}
|
||||
{#if isAdmin}
|
||||
<div class="dash-actions">
|
||||
<a href="/import" class="btn btn-ghost btn-sm">Import CSV</a>
|
||||
<a href="/participants" class="btn btn-ghost btn-sm">Manage Participants</a>
|
||||
|
|
@ -158,8 +159,8 @@
|
|||
{/if}
|
||||
|
||||
<p class="text-muted" style="font-size:0.85rem;margin-top:2rem">
|
||||
Welcome, <strong style="color:var(--c-text)">{session?.user?.username}</strong>
|
||||
· <span class="badge badge-role">{session?.user?.role}</span>
|
||||
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}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -18,9 +18,10 @@
|
|||
let editDesc = $state('')
|
||||
let saving = $state(false)
|
||||
|
||||
const role = $derived(session?.user?.role ?? '')
|
||||
const canCreate = $derived(['admin', 'ticketing', 'staffing'].includes(role))
|
||||
const canDelete = $derived(['admin', 'ticketing'].includes(role))
|
||||
const roles = $derived(session?.user?.roles ?? [])
|
||||
function hasRole(...allowed) { return roles.some(r => allowed.includes(r)) }
|
||||
const canCreate = $derived(hasRole('admin', 'staffing'))
|
||||
const canDelete = $derived(hasRole('admin'))
|
||||
|
||||
const allDepts = liveQuery(() =>
|
||||
db.departments.filter(d => !d.deleted_at).toArray()
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
|
||||
let { onlogin } = $props()
|
||||
|
||||
let username = $state('')
|
||||
let email = $state('')
|
||||
let password = $state('')
|
||||
let error = $state('')
|
||||
let loading = $state(false)
|
||||
|
|
@ -14,7 +14,7 @@
|
|||
error = ''
|
||||
loading = true
|
||||
try {
|
||||
const { token, user } = await api.login(username, password)
|
||||
const { token, user } = await api.login(email, password)
|
||||
await saveSession(token, user)
|
||||
onlogin({ token, user })
|
||||
} catch (err) {
|
||||
|
|
@ -34,8 +34,8 @@
|
|||
{/if}
|
||||
<form onsubmit={submit}>
|
||||
<div class="form-group">
|
||||
<label for="username">Username</label>
|
||||
<input id="username" bind:value={username} autocomplete="username" required />
|
||||
<label for="email">Email</label>
|
||||
<input id="email" type="email" bind:value={email} autocomplete="email" required />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="password">Password</label>
|
||||
|
|
|
|||
|
|
@ -40,8 +40,9 @@
|
|||
let newTicketType = $state('')
|
||||
let newTicketExtId = $state('')
|
||||
|
||||
const role = $derived(session?.user?.role ?? '')
|
||||
const canManage = $derived(['admin', 'ticketing'].includes(role))
|
||||
const roles = $derived(session?.user?.roles ?? [])
|
||||
function hasRole(...allowed) { return roles.some(r => allowed.includes(r)) }
|
||||
const canManage = $derived(hasRole('admin'))
|
||||
|
||||
const allParticipants = liveQuery(() => db.participants.toArray())
|
||||
const allTickets = liveQuery(() => db.tickets.toArray())
|
||||
|
|
|
|||
|
|
@ -25,8 +25,9 @@
|
|||
let assignVolID = $state(0)
|
||||
let assigning = $state(false)
|
||||
|
||||
const role = $derived(session?.user?.role ?? '')
|
||||
const canManage = $derived(['admin', 'ticketing', 'staffing', 'colead'].includes(role))
|
||||
const roles = $derived(session?.user?.roles ?? [])
|
||||
function hasRole(...allowed) { return roles.some(r => allowed.includes(r)) }
|
||||
const canManage = $derived(hasRole('admin', 'staffing', 'colead'))
|
||||
const myDeptIDs = $derived(session?.user?.department_ids ?? [])
|
||||
|
||||
const allDepts = liveQuery(() =>
|
||||
|
|
@ -54,7 +55,7 @@
|
|||
// Departments visible to this user
|
||||
const visibleDepts = $derived.by(() => {
|
||||
const depts = $allDepts ?? []
|
||||
if (role === 'colead') return depts.filter(d => myDeptIDs.includes(d.id))
|
||||
if (hasRole('colead') && !hasRole('admin', 'staffing')) return depts.filter(d => myDeptIDs.includes(d.id))
|
||||
return depts
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -12,13 +12,14 @@
|
|||
|
||||
let showAdd = $state(false)
|
||||
let adding = $state(false)
|
||||
let newUsername = $state('')
|
||||
let newEmail = $state('')
|
||||
let newName = $state('')
|
||||
let newPassword = $state('')
|
||||
let newRole = $state('gate')
|
||||
let newRoles = $state([])
|
||||
let newDeptIDs = $state([])
|
||||
|
||||
let editID = $state(null)
|
||||
let editRole = $state('')
|
||||
let editRoles = $state([])
|
||||
let editDeptIDs = $state([])
|
||||
let editPassword = $state('')
|
||||
let saving = $state(false)
|
||||
|
|
@ -28,7 +29,7 @@
|
|||
.then(arr => arr.sort((a, b) => a.name.localeCompare(b.name)))
|
||||
)
|
||||
|
||||
const roles = ['admin', 'ticketing', 'staffing', 'colead', 'gatekeeper']
|
||||
const availableRoles = ['admin', 'staffing', 'colead', 'gatekeeper']
|
||||
|
||||
const me = $derived(session?.user?.id)
|
||||
|
||||
|
|
@ -51,15 +52,16 @@
|
|||
error = ''
|
||||
try {
|
||||
const u = await api.users.create({
|
||||
username: newUsername,
|
||||
email: newEmail,
|
||||
preferred_name: newName,
|
||||
password: newPassword,
|
||||
role: newRole,
|
||||
roles: newRoles,
|
||||
department_ids: newDeptIDs,
|
||||
})
|
||||
users = [...users, u]
|
||||
showAdd = false
|
||||
newUsername = newPassword = ''
|
||||
newRole = 'gate'
|
||||
newEmail = newName = newPassword = ''
|
||||
newRoles = []
|
||||
newDeptIDs = []
|
||||
} catch (err) {
|
||||
error = err.message
|
||||
|
|
@ -70,7 +72,7 @@
|
|||
|
||||
function startEdit(u) {
|
||||
editID = u.id
|
||||
editRole = u.role
|
||||
editRoles = [...(u.roles || [])]
|
||||
editDeptIDs = [...(u.department_ids || [])]
|
||||
editPassword = ''
|
||||
}
|
||||
|
|
@ -83,7 +85,7 @@
|
|||
saving = true
|
||||
error = ''
|
||||
try {
|
||||
const payload = { role: editRole, department_ids: editDeptIDs }
|
||||
const payload = { roles: editRoles, 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)
|
||||
|
|
@ -96,7 +98,7 @@
|
|||
}
|
||||
|
||||
async function deleteUser(u) {
|
||||
if (!confirm(`Delete user "${u.username}"? This cannot be undone.`)) return
|
||||
if (!confirm(`Remove login access for "${u.preferred_name || u.email}"? Their participant record will be kept.`)) return
|
||||
try {
|
||||
await api.users.delete(u.id)
|
||||
users = users.filter(x => x.id !== u.id)
|
||||
|
|
@ -105,7 +107,7 @@
|
|||
}
|
||||
}
|
||||
|
||||
function toggleDept(id, list) {
|
||||
function toggleItem(id, list) {
|
||||
const idx = list.indexOf(id)
|
||||
if (idx === -1) return [...list, id]
|
||||
return list.filter(x => x !== id)
|
||||
|
|
@ -117,7 +119,7 @@
|
|||
}
|
||||
|
||||
function roleLabel(r) {
|
||||
return { admin: 'Admin', ticketing: 'Ticketing', staffing: 'Staffing', colead: 'Colead', gatekeeper: 'Gatekeeper' }[r] || r
|
||||
return { admin: 'Admin', staffing: 'Staffing', colead: 'Colead', gatekeeper: 'Gatekeeper' }[r] || r
|
||||
}
|
||||
</script>
|
||||
|
||||
|
|
@ -132,7 +134,6 @@
|
|||
<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
|
||||
|
|
@ -150,20 +151,29 @@
|
|||
<form onsubmit={addUser}>
|
||||
<div class="form-grid" 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" />
|
||||
<label for="u-email">Email *</label>
|
||||
<input id="u-email" type="email" bind:value={newEmail} required placeholder="email@example.com" autocomplete="off" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="u-name">Name</label>
|
||||
<input id="u-name" bind:value={newName} placeholder="Preferred name" 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 class="form-group">
|
||||
<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">
|
||||
{#each availableRoles as r}
|
||||
<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={newRoles.includes(r)}
|
||||
onchange={() => newRoles = toggleItem(r, newRoles)} />
|
||||
{roleLabel(r)}
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{#if ($allDepts ?? []).length > 0}
|
||||
|
|
@ -174,7 +184,7 @@
|
|||
<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)} />
|
||||
onchange={() => newDeptIDs = toggleItem(d.id, newDeptIDs)} />
|
||||
<span class="dept-dot" style="background:{d.color}"></span>
|
||||
{d.name}
|
||||
</label>
|
||||
|
|
@ -204,8 +214,8 @@
|
|||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Username</th>
|
||||
<th>Role</th>
|
||||
<th>Name</th>
|
||||
<th>Roles</th>
|
||||
<th>Departments</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
|
|
@ -214,13 +224,18 @@
|
|||
{#each users as u (u.id)}
|
||||
{#if editID === u.id}
|
||||
<tr class="edit-row">
|
||||
<td class="td-name"><strong>{u.username}</strong> {#if u.id === me}<span class="badge badge-role">you</span>{/if}</td>
|
||||
<td class="td-name"><strong>{u.preferred_name || u.email}</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>
|
||||
<div style="display:flex;flex-wrap:wrap;gap:0.4rem">
|
||||
{#each availableRoles as r}
|
||||
<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={editRoles.includes(r)}
|
||||
onchange={() => editRoles = toggleItem(r, editRoles)} />
|
||||
{roleLabel(r)}
|
||||
</label>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
{#if ($allDepts ?? []).length > 0}
|
||||
|
|
@ -229,7 +244,7 @@
|
|||
<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)} />
|
||||
onchange={() => editDeptIDs = toggleItem(d.id, editDeptIDs)} />
|
||||
{d.name}
|
||||
</label>
|
||||
{/each}
|
||||
|
|
@ -251,18 +266,19 @@
|
|||
{:else}
|
||||
<tr>
|
||||
<td class="td-name">
|
||||
<strong>{u.username}</strong>
|
||||
<strong>{u.preferred_name || u.email}</strong>
|
||||
{#if u.id === me}
|
||||
<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><span class="badge badge-role">{roleLabel(u.role)}</span></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">
|
||||
<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>
|
||||
<button class="btn btn-danger btn-sm" onclick={() => deleteUser(u)}>Remove</button>
|
||||
{/if}
|
||||
</div>
|
||||
</td>
|
||||
|
|
|
|||
|
|
@ -25,14 +25,15 @@
|
|||
let editNote = $state('')
|
||||
let saving = $state(false)
|
||||
|
||||
const role = $derived(session?.user?.role ?? '')
|
||||
const canManage = $derived(['admin', 'ticketing', 'staffing', 'colead'].includes(role))
|
||||
const canConfirm = $derived(['admin', 'staffing', 'colead'].includes(role))
|
||||
const roles = $derived(session?.user?.roles ?? [])
|
||||
function hasRole(...allowed) { return roles.some(r => allowed.includes(r)) }
|
||||
const canManage = $derived(hasRole('admin', 'staffing', 'colead'))
|
||||
const canConfirm = $derived(hasRole('admin', 'staffing', 'colead'))
|
||||
const myDeptIDs = $derived(session?.user?.department_ids ?? [])
|
||||
|
||||
let deptInitialized = $state(false)
|
||||
$effect(() => {
|
||||
if (!deptInitialized && role === 'colead' && myDeptIDs.length > 0) {
|
||||
if (!deptInitialized && hasRole('colead') && !hasRole('admin', 'staffing') && myDeptIDs.length > 0) {
|
||||
filterDept = String(myDeptIDs[0])
|
||||
deptInitialized = true
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue