Web: per-user library grants UI

Multi-user was the central design decision and grants were API-only, which
made the whole thing unusable from the browser.

- Users screen lists each account with an expandable library panel.
- Read and Download checkboxes per library, loaded lazily per user.
- Clearing both flags deletes the grant, so "no grant" and "granted nothing"
  stay the same state rather than two representations of the same thing.
- Admins show a note instead of checkboxes: they read every library by design.

Verified end to end: granted library visible and its items readable, ungranted
library 404s on both item access and search, revoke takes effect immediately.
This commit is contained in:
mithral 2026-10-09 21:24:04 -04:00
parent b0151fce00
commit cebe1977e4
2 changed files with 142 additions and 18 deletions

View file

@ -168,6 +168,22 @@ export const api = {
createUser: (username: string, password: string, display_name: string, is_admin: boolean) =>
request<{ user: User }>('POST', '/api/v1/users', { username, password, display_name, is_admin }),
userGrants: (userId: number) =>
request<{ grants: { library_id: number; can_read: boolean; can_download: boolean }[] }>(
'GET',
`/api/v1/users/${userId}/grants`,
),
putGrant: (userId: number, libraryId: number, canRead: boolean, canDownload: boolean) =>
request<{ user_id: number; library_id: number; can_read: boolean; can_download: boolean }>(
'PUT',
`/api/v1/users/${userId}/grants/${libraryId}`,
{ can_read: canRead, can_download: canDownload },
),
deleteGrant: (userId: number, libraryId: number) =>
request<void>('DELETE', `/api/v1/users/${userId}/grants/${libraryId}`),
}
export interface SearchResult {

View file

@ -1,8 +1,9 @@
<script lang="ts">
import { onMount } from 'svelte'
import { api, ApiError, type User } from '../api'
import { api, ApiError, type Library, type User } from '../api'
let users = $state<User[]>([])
let libraries = $state<Library[]>([])
let loading = $state(true)
let error = $state<string | null>(null)
let busy = $state(false)
@ -12,12 +13,19 @@
let password = $state('')
let isAdmin = $state(false)
// Grants are loaded lazily per user, keyed by user id.
let expanded = $state<number | null>(null)
let grants = $state<Record<number, { can_read: boolean; can_download: boolean }>>({})
let loadingGrants = $state(false)
onMount(load)
async function load() {
loading = true
try {
users = (await api.users()).users
const [u, l] = await Promise.all([api.users(), api.libraries()])
users = u.users
libraries = l.libraries
} catch (err) {
error = err instanceof ApiError ? err.message : 'could not load users'
} finally {
@ -40,6 +48,43 @@
busy = false
}
}
async function toggleGrants(userId: number) {
if (expanded === userId) {
expanded = null
return
}
expanded = userId
loadingGrants = true
try {
const res = await api.userGrants(userId)
const map: Record<number, { can_read: boolean; can_download: boolean }> = {}
for (const g of res.grants) map[g.library_id] = { can_read: g.can_read, can_download: g.can_download }
grants = map
} catch (err) {
error = err instanceof ApiError ? err.message : 'could not load grants'
} finally {
loadingGrants = false
}
}
// Changing either flag rewrites the whole grant. Clearing both removes it, so
// "no grant" and "granted nothing" stay the same thing.
async function setGrant(userId: number, libraryId: number, read: boolean, download: boolean) {
try {
if (!read && !download) {
await api.deleteGrant(userId, libraryId)
const next = { ...grants }
delete next[libraryId]
grants = next
return
}
await api.putGrant(userId, libraryId, read, download)
grants = { ...grants, [libraryId]: { can_read: read, can_download: download } }
} catch (err) {
error = err instanceof ApiError ? err.message : 'could not update the grant'
}
}
</script>
{#if error}<div class="alert">{error}</div>{/if}
@ -50,12 +95,53 @@
<div class="center"><div class="spinner"></div></div>
{:else}
{#each users as u (u.id)}
<div class="userblock">
<div class="row">
<div class="id">
<strong>{u.display_name || u.username}</strong>
<span class="muted mono">{u.username}</span>
</div>
{#if u.is_admin}<span class="badge">admin</span>{/if}
<button class="btn btn-ghost" onclick={() => toggleGrants(u.id)}>
{expanded === u.id ? 'Hide libraries' : 'Libraries'}
</button>
</div>
{#if expanded === u.id}
<div class="grants">
{#if u.is_admin}
<p class="muted note">Administrators can read every library.</p>
{:else if loadingGrants}
<div class="spinner"></div>
{:else if libraries.length === 0}
<p class="muted note">No libraries exist yet.</p>
{:else}
{#each libraries as lib (lib.id)}
<div class="grantrow">
<span class="libname">{lib.name} <span class="faint">{lib.kind}</span></span>
<label class="check">
<input
type="checkbox"
checked={grants[lib.id]?.can_read ?? false}
onchange={(e) =>
setGrant(u.id, lib.id, e.currentTarget.checked, grants[lib.id]?.can_download ?? false)}
/>
Read
</label>
<label class="check">
<input
type="checkbox"
checked={grants[lib.id]?.can_download ?? false}
onchange={(e) =>
setGrant(u.id, lib.id, grants[lib.id]?.can_read ?? false, e.currentTarget.checked)}
/>
Download
</label>
</div>
{/each}
{/if}
</div>
{/if}
</div>
{/each}
{/if}
@ -82,7 +168,6 @@
<button class="btn btn-primary" type="submit" disabled={busy || !username || password.length < 8}>
Create user
</button>
<p class="muted hint">Grant library access per user from the API; a grants UI is coming.</p>
</form>
</div>
@ -101,24 +186,50 @@
place-items: center;
padding: var(--space-5);
}
.userblock {
border-bottom: 1px solid var(--border);
}
.userblock:last-child {
border-bottom: none;
}
.row {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
padding: var(--space-3) var(--space-4);
border-bottom: 1px solid var(--border);
}
.row:last-child {
border-bottom: none;
}
.id {
display: flex;
flex-direction: column;
flex: 1;
}
.id .muted {
font-size: 12px;
}
.grants {
padding: 0 var(--space-4) var(--space-4);
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.note {
margin: 0;
font-size: 12px;
}
.grantrow {
display: flex;
align-items: center;
gap: var(--space-3);
}
.libname {
flex: 1;
font-size: 13px;
}
.libname .faint {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.form {
display: flex;
flex-direction: column;
@ -128,12 +239,9 @@
.check {
display: flex;
align-items: center;
gap: var(--space-2);
gap: 6px;
font-size: 13px;
color: var(--text-muted);
}
.hint {
font-size: 12px;
margin: 0;
white-space: nowrap;
}
</style>