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:
parent
b0151fce00
commit
cebe1977e4
2 changed files with 142 additions and 18 deletions
|
|
@ -168,6 +168,22 @@ export const api = {
|
||||||
|
|
||||||
createUser: (username: string, password: string, display_name: string, is_admin: boolean) =>
|
createUser: (username: string, password: string, display_name: string, is_admin: boolean) =>
|
||||||
request<{ user: User }>('POST', '/api/v1/users', { username, password, display_name, is_admin }),
|
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 {
|
export interface SearchResult {
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte'
|
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 users = $state<User[]>([])
|
||||||
|
let libraries = $state<Library[]>([])
|
||||||
let loading = $state(true)
|
let loading = $state(true)
|
||||||
let error = $state<string | null>(null)
|
let error = $state<string | null>(null)
|
||||||
let busy = $state(false)
|
let busy = $state(false)
|
||||||
|
|
@ -12,12 +13,19 @@
|
||||||
let password = $state('')
|
let password = $state('')
|
||||||
let isAdmin = $state(false)
|
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)
|
onMount(load)
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
loading = true
|
loading = true
|
||||||
try {
|
try {
|
||||||
users = (await api.users()).users
|
const [u, l] = await Promise.all([api.users(), api.libraries()])
|
||||||
|
users = u.users
|
||||||
|
libraries = l.libraries
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error = err instanceof ApiError ? err.message : 'could not load users'
|
error = err instanceof ApiError ? err.message : 'could not load users'
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -40,6 +48,43 @@
|
||||||
busy = false
|
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>
|
</script>
|
||||||
|
|
||||||
{#if error}<div class="alert">{error}</div>{/if}
|
{#if error}<div class="alert">{error}</div>{/if}
|
||||||
|
|
@ -50,12 +95,53 @@
|
||||||
<div class="center"><div class="spinner"></div></div>
|
<div class="center"><div class="spinner"></div></div>
|
||||||
{:else}
|
{:else}
|
||||||
{#each users as u (u.id)}
|
{#each users as u (u.id)}
|
||||||
<div class="row">
|
<div class="userblock">
|
||||||
<div class="id">
|
<div class="row">
|
||||||
<strong>{u.display_name || u.username}</strong>
|
<div class="id">
|
||||||
<span class="muted mono">{u.username}</span>
|
<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>
|
</div>
|
||||||
{#if u.is_admin}<span class="badge">admin</span>{/if}
|
|
||||||
|
{#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>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
@ -82,7 +168,6 @@
|
||||||
<button class="btn btn-primary" type="submit" disabled={busy || !username || password.length < 8}>
|
<button class="btn btn-primary" type="submit" disabled={busy || !username || password.length < 8}>
|
||||||
Create user
|
Create user
|
||||||
</button>
|
</button>
|
||||||
<p class="muted hint">Grant library access per user from the API; a grants UI is coming.</p>
|
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -101,24 +186,50 @@
|
||||||
place-items: center;
|
place-items: center;
|
||||||
padding: var(--space-5);
|
padding: var(--space-5);
|
||||||
}
|
}
|
||||||
|
.userblock {
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.userblock:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
.row {
|
.row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
|
||||||
gap: var(--space-3);
|
gap: var(--space-3);
|
||||||
padding: var(--space-3) var(--space-4);
|
padding: var(--space-3) var(--space-4);
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
.row:last-child {
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
}
|
||||||
.id {
|
.id {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
flex: 1;
|
||||||
}
|
}
|
||||||
.id .muted {
|
.id .muted {
|
||||||
font-size: 12px;
|
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 {
|
.form {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
@ -128,12 +239,9 @@
|
||||||
.check {
|
.check {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--space-2);
|
gap: 6px;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
white-space: nowrap;
|
||||||
.hint {
|
|
||||||
font-size: 12px;
|
|
||||||
margin: 0;
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue