Files
pad/web/src/lib/components/settings/StorageTab.svelte
T
xarmian 504d348917 feat(attachments): Settings → Storage tab with attachment list (TASK-882) (#303)
* feat(attachments): Settings → Storage tab with attachment list (TASK-882)

Adds the Settings → Storage tab and the underlying list/delete API
endpoints so workspace owners can audit and reclaim attachment bytes.

Backend (TASK-882 needs this — there was no list/delete API yet):
- store.WorkspaceAttachments: paginated list with filter (category,
  attached/unattached, collection_id) + sort allowlist (size, filename,
  created_at — each with desc variant). LEFT JOIN to items + collections
  enriches each row with item_title/slug + collection_slug for the
  "in [[Item]]" link. Hides derived (thumbnail) rows by default — they
  count toward quota but are managed automatically and would clutter
  the user-facing list.
- store.SoftDeleteAttachment: tombstones the row + every variant. Blob
  on disk stays put; orphan GC reclaims past the grace period (TASK-886).
- GET /workspaces/{ws}/attachments — viewer+, returns
  {attachments, total, limit, offset}.
- DELETE /workspaces/{ws}/attachments/{id} — editor+. Refuses to delete
  derived rows directly (returns 400 with derived_attachment code) and
  invalidates the storage-usage cache.

Frontend:
- StorageTab.svelte component (lib/components/settings) with usage bar
  (color thresholds at 80%/100%, override badge), 5-select filter row
  (category, item, collection, sort, page size), attachment list with
  thumbnails (image variants via thumb-sm, emoji icon otherwise), item
  link, MIME, size, date, and per-row delete with confirm() dialog.
  Pagination footer with Prev/Next + "showing X–Y of Z".
- TS api.attachments.list() / delete() + types.
- Wired as a new "Storage" tab on the workspace settings page.

Tests:
- TestListAttachments_Pagination: 3 uploads, default + size-asc sort,
  limit/offset paging.
- TestListAttachments_HidesDerived: synthetic thumbnail row, asserts
  the list excludes parent_id != NULL rows.
- TestDeleteAttachment_HappyPath: upload → delete → list empty →
  storage usage drops to 0 (cache invalidation hook fires) → second
  delete returns 404.
- TestDeleteAttachment_DerivedRefused: thumbnail rows can't be deleted
  directly via the API.

Parent: PLAN-866.

* fix(attachments): collection visibility + category gaps + item ref shape per Codex (round 1)

Three findings from Codex on PR #303 round 1:

P1 — Collection visibility leak. The storage list returned all
workspace attachments without applying per-user collection access,
so a member with collection_access=specific would receive hidden
collections' attachment IDs/filenames/item titles and could then
pull the bytes via the existing download endpoint.

Fixed by threading visibleCollectionIDs(r, workspaceID) through to
the store filter. nil = admin/no restriction; empty slice = zero
visible collections (zero rows by design); explicit set = restrict
i.collection_id IN (...). Orphans (item_id IS NULL) are excluded
for restricted users since their filenames would still leak.

P2 — item_ref shape didn't match the route. The store synthesized
"<collection_slug>/<item_number>" and the UI inserted it verbatim
into the URL, producing /user/ws/tasks/tasks/5. Dropped item_ref
entirely; UI now builds URLs from item_slug + collection_slug
which is the actual route shape.

P2 — Category filter coverage. mimePrefixForCategory only handled
image/video/audio. Selecting Documents/Text/Archive/Other in the
UI silently passed through with no MIME predicate so the list
showed everything. Replaced with mimePredicateForCategory which
emits the right SQL fragment per bucket: prefix LIKE for the type/
buckets, explicit IN list for document/text/archive (mirroring the
allowlist in internal/attachments/mime.go), and a NOT-IN composite
for "other".

Tests:
- TestWorkspaceAttachments_VisibilityFilter: admin sees all 3 rows;
  restricted to one collection sees only that collection's row +
  orphan suppressed; empty visibility yields zero rows.
- TestWorkspaceAttachments_CategoryFilters: image/document/text/
  archive/other each return exactly the matching MIME types.

* fix(attachments): item-level visibility on list + delete per Codex (round 2)

Two more findings from Codex on PR #303 round 2:

1. The list filter used VisibleCollectionIDs alone — but that set
   includes collections containing any item-level grant for the user.
   A guest with one item granted in collection B would still receive
   attachment metadata for every item in collection B. Replaced with
   the (fullCollIDs, grantedItemIDs) tuple from guestResourceFilter so
   the SQL ORs collection-level full access against per-item grants,
   matching how handlers_search / handlers_activity narrow lists.

2. The delete endpoint validated workspace membership but never
   checked the attachment's parent item is visible to the caller.
   An editor with restricted collection access could delete
   attachments in hidden collections by guessing/obtaining the
   attachment ID. Added requireItemVisible after fetching the parent
   item, plus a fallback gate for orphan attachments (item_id IS
   NULL) so restricted users get 404 there as well.

Store-level filter renamed: VisibleCollectionIDs → Restricted +
FullCollectionIDs + GrantedItemIDs. Tests cover the collection-only,
item-grant-only, and zero-visibility paths.

* fix(attachments): allow deleting attachments when parent item is soft-deleted (round 3)

Codex P2 from PR #303 round 3: the storage list intentionally surfaces
attachments whose parent item has been soft-deleted (so the user sees
what's still consuming quota), but the delete handler used GetItem,
which filters soft-deleted out and returned 404 before
SoftDeleteAttachment could run — turning every Delete button on those
rows into a no-op.

Fixed by adding store.GetItemIncludeDeleted (mirroring the existing
GetItemBySlugIncludeDeleted) and switching the delete path to use it.
The visibility check still keys off the (still-set) collection_id, so
soft-deleting an item doesn't escalate access — restricted users still
hit requireItemVisible's 404 if they couldn't see the parent.

Regression test: create item → attach → soft-delete item → list still
returns the row → delete returns 204.

* fix(attachments): list surfaces attachments under soft-deleted parents (round 4)

Codex round-4 finding: WorkspaceAttachments still LEFT JOIN'd items
with AND i.deleted_at IS NULL, so attachments whose parent item was
soft-deleted disappeared from the list — even though the previous
round wired GetItemIncludeDeleted on the delete path. Net effect:
restricted editors with access to that collection couldn't discover
the row in the UI; only full-access users saw it as an orphan-looking
entry.

Fix: drop the deleted_at filter from the JOIN. The collection ACL
predicate (i.collection_id IN ...) now sees the (still-set)
collection_id from the soft-deleted item, so visibility behaves
consistently for live and tombstoned parents. Soft-deleted items
don't escalate access — the collection_id stays put.

UX: response now carries item_deleted=true when the parent is
soft-deleted; the StorageTab renders the title with strike-through
+ a small "deleted" badge instead of a clickable link (which would
404).

Tests:
- store-level: admin/full-access sees the row + ItemDeleted flag,
  restricted-to-correct-collection sees it, restricted-to-other-
  collection does not.
- (existing TestDeleteAttachment_AfterParentSoftDeleted continues
  to pass on the handler side.)
2026-04-29 17:44:12 -04:00

702 lines
18 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
import { onMount } from 'svelte';
import { page } from '$app/state';
import { api } from '$lib/api/client';
import type {
AttachmentListItem,
AttachmentListFilters,
AttachmentListResponse,
Collection,
WorkspaceStorageInfo
} from '$lib/types';
import { toastStore } from '$lib/stores/toast.svelte';
// ── Props ────────────────────────────────────────────────────────────────
interface Props {
wsSlug: string;
collections: Collection[];
}
let { wsSlug, collections }: Props = $props();
// ── State ────────────────────────────────────────────────────────────────
let loading = $state(true);
let usage = $state<WorkspaceStorageInfo | null>(null);
let attachments = $state<AttachmentListItem[]>([]);
let total = $state(0);
let limit = $state(50);
let offset = $state(0);
// Filters (UI selections — empty string means "All" / not applied)
type CategoryValue = '' | 'image' | 'video' | 'audio' | 'document' | 'text' | 'archive' | 'other';
type ItemValue = '' | 'attached' | 'unattached';
type SortValue =
| 'created_at_desc'
| 'created_at'
| 'size_desc'
| 'size'
| 'filename'
| 'filename_desc';
let filterCategory = $state<CategoryValue>('');
let filterItem = $state<ItemValue>('');
let filterCollection = $state<string>('');
let sortValue = $state<SortValue>('created_at_desc');
// ── Helpers ──────────────────────────────────────────────────────────────
// Same algorithm as web/src/routes/console/billing/+page.svelte. Picks a
// unit so the displayed value is < 1024; bump thresholds nudged down half
// the previous unit so 1,048,575 bytes reads as "1.0 MB" rather than the
// misleading "1024 KB" you'd get from a straight Math.round at the KB tier.
function formatBytes(bytes: number): string {
if (bytes < 0) return `${bytes} B`;
const KB = 1024;
const MB = KB * 1024;
const GB = MB * 1024;
const bumpGB = GB - MB / 2;
const bumpMB = MB - KB / 2;
if (bytes >= bumpGB) return formatUnit(bytes / GB, 'GB');
if (bytes >= bumpMB) return formatUnit(bytes / MB, 'MB');
if (bytes >= KB) return formatUnit(bytes / KB, 'KB');
return `${bytes} B`;
}
function formatUnit(value: number, unit: string): string {
if (value >= 10) return `${Math.round(value)} ${unit}`;
return `${value.toFixed(1)} ${unit}`;
}
function formatDate(iso: string): string {
try {
return new Date(iso).toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric'
});
} catch {
return iso;
}
}
function categoryIcon(mime: string): string {
if (mime.startsWith('image/')) return '🖼️';
if (mime.startsWith('video/')) return '🎬';
if (mime.startsWith('audio/')) return '🔊';
if (mime.startsWith('text/')) return '📄';
if (mime === 'application/pdf') return '📄';
if (
mime === 'application/zip' ||
mime === 'application/x-tar' ||
mime === 'application/gzip' ||
mime === 'application/x-7z-compressed' ||
mime === 'application/x-rar-compressed'
)
return '📦';
if (
mime.startsWith('application/vnd.openxmlformats') ||
mime.startsWith('application/vnd.ms-') ||
mime.startsWith('application/vnd.oasis') ||
mime === 'application/msword'
)
return '📄';
return '❓';
}
function isImage(mime: string): boolean {
return mime.startsWith('image/');
}
// ── Derived values ───────────────────────────────────────────────────────
let usagePercent = $derived.by(() => {
if (!usage || usage.limit_bytes < 0) return 0;
if (usage.limit_bytes === 0) return 0;
return (usage.used_bytes / usage.limit_bytes) * 100;
});
let usageBarClass = $derived.by(() => {
if (!usage || usage.limit_bytes < 0) return '';
if (usagePercent >= 100) return 'crit';
if (usagePercent >= 80) return 'warn';
return '';
});
let pageStart = $derived(total === 0 ? 0 : offset + 1);
let pageEnd = $derived(Math.min(offset + attachments.length, total));
let canPrev = $derived(offset > 0);
let canNext = $derived(offset + limit < total);
let username = $derived(page.params.username ?? '');
// ── Data loading ─────────────────────────────────────────────────────────
function buildFilters(): AttachmentListFilters {
const f: AttachmentListFilters = {
limit,
offset,
sort: sortValue
};
if (filterCategory) f.category = filterCategory;
if (filterItem) f.item = filterItem;
if (filterCollection) f.collection = filterCollection;
return f;
}
async function loadUsage() {
try {
usage = await api.attachments.storageUsage(wsSlug);
} catch (err) {
const msg = err instanceof Error ? err.message : 'Failed to load storage usage';
toastStore.show(msg, 'error');
}
}
async function loadList() {
try {
const resp: AttachmentListResponse = await api.attachments.list(wsSlug, buildFilters());
attachments = resp.attachments ?? [];
total = resp.total ?? 0;
limit = resp.limit ?? limit;
offset = resp.offset ?? offset;
} catch (err) {
const msg = err instanceof Error ? err.message : 'Failed to load attachments';
toastStore.show(msg, 'error');
}
}
async function reload() {
await Promise.all([loadList(), loadUsage()]);
}
onMount(async () => {
try {
await Promise.all([loadList(), loadUsage()]);
} finally {
loading = false;
}
});
// Filter / sort changes reset to the first page and refetch. Using an
// explicit handler instead of an $effect keeps the side-effect tied to
// the user action and avoids an effect that mutates state + calls a
// fetch on every dependency tick.
function onFiltersChanged() {
offset = 0;
loadList();
}
// ── Actions ──────────────────────────────────────────────────────────────
async function handleDelete(att: AttachmentListItem) {
const ok = confirm(
`Delete ${att.filename}? The blob is reclaimed by garbage collection after a grace period.`
);
if (!ok) return;
try {
await api.attachments.delete(wsSlug, att.id);
toastStore.show(`Deleted ${att.filename}`, 'success');
await reload();
} catch (err) {
const msg = err instanceof Error ? err.message : 'Failed to delete attachment';
toastStore.show(msg, 'error');
}
}
function gotoPrev() {
if (!canPrev) return;
offset = Math.max(0, offset - limit);
loadList();
}
function gotoNext() {
if (!canNext) return;
offset = offset + limit;
loadList();
}
function itemHref(att: AttachmentListItem): string {
// Item route is /{username}/{wsSlug}/{collection_slug}/{item_slug}.
// item_ref (collection-prefix + number, e.g. "TASK-5") doesn't
// match the route shape — only item_slug does — so we ignore
// item_ref here even if a future API surfaces one.
const collSlug = att.collection_slug ?? '';
const itemSlug = att.item_slug ?? '';
if (!collSlug || !itemSlug) return '#';
if (username) {
return `/${username}/${wsSlug}/${collSlug}/${itemSlug}`;
}
// Settings page sits at /{username}/{wsSlug}/settings, so two
// levels up is the item route's parent.
return `../../${collSlug}/${itemSlug}`;
}
</script>
<div class="storage-tab">
{#if loading}
<p class="empty-text">Loading storage…</p>
{:else}
<!-- Usage bar -->
<div class="card usage-bar-card">
{#if usage}
<div class="usage-line">
{#if usage.limit_bytes >= 0}
<span>
<strong>{formatBytes(usage.used_bytes)}</strong>
used of
<strong>{formatBytes(usage.limit_bytes)}</strong>
({usagePercent.toFixed(1)}%)
</span>
{:else}
<span>
<strong>{formatBytes(usage.used_bytes)}</strong>
used
<span class="usage-unlimited">(unlimited)</span>
</span>
{/if}
{#if usage.override_active}
<span class="override-badge">custom override</span>
{/if}
</div>
{#if usage.limit_bytes >= 0}
<div class="usage-bar">
<div
class="usage-fill {usageBarClass}"
style:width="{Math.min(100, usagePercent)}%"
></div>
</div>
{/if}
{#if usage.override_active && usage.plan}
<div class="usage-subline">
Plan: {usage.plan} — admin override active
</div>
{/if}
{:else}
<p class="empty-text">Unable to load usage info.</p>
{/if}
</div>
<!-- Filters -->
<div class="filter-row">
<label>
Category
<select
class="role-select"
bind:value={filterCategory}
onchange={onFiltersChanged}
>
<option value="">All</option>
<option value="image">Images</option>
<option value="video">Videos</option>
<option value="audio">Audio</option>
<option value="document">Documents</option>
<option value="text">Text</option>
<option value="archive">Archive</option>
<option value="other">Other</option>
</select>
</label>
<label>
Item
<select
class="role-select"
bind:value={filterItem}
onchange={onFiltersChanged}
>
<option value="">All</option>
<option value="attached">Attached</option>
<option value="unattached">Unattached</option>
</select>
</label>
<label>
Collection
<select
class="role-select"
bind:value={filterCollection}
onchange={onFiltersChanged}
>
<option value="">All</option>
{#each collections as coll (coll.id)}
<option value={coll.id}>{coll.name}</option>
{/each}
</select>
</label>
<label>
Sort
<select
class="role-select"
bind:value={sortValue}
onchange={onFiltersChanged}
>
<option value="created_at_desc">Newest first</option>
<option value="created_at">Oldest first</option>
<option value="size_desc">Largest first</option>
<option value="size">Smallest first</option>
<option value="filename">Filename A→Z</option>
<option value="filename_desc">Filename Z→A</option>
</select>
</label>
<label>
Per page
<select
class="role-select"
bind:value={limit}
onchange={onFiltersChanged}
>
<option value={25}>25</option>
<option value={50}>50</option>
<option value={100}>100</option>
</select>
</label>
</div>
<!-- Attachment list -->
{#if total === 0}
<p class="empty-text">
No attachments yet — paste or drag a file into any item to upload one.
</p>
{:else}
<div class="att-list">
{#each attachments as att (att.id)}
<div class="att-row card">
<a
class="att-thumb"
href={api.attachments.downloadUrl(wsSlug, att.id)}
target="_blank"
rel="noopener"
aria-label="Open {att.filename}"
>
{#if isImage(att.mime_type)}
<img
src={api.attachments.downloadUrl(wsSlug, att.id, 'thumb-sm')}
alt={att.filename}
loading="lazy"
/>
{:else}
<span aria-hidden="true">{categoryIcon(att.mime_type)}</span>
{/if}
</a>
<div class="att-meta">
<div class="att-line1">
<span class="att-filename" title={att.filename}>{att.filename}</span>
</div>
<div class="att-line2">
<span class="att-size">{formatBytes(att.size_bytes)}</span>
·
<span class="att-mime mono">{att.mime_type}</span>
·
<span class="att-date">{formatDate(att.created_at)}</span>
</div>
<div class="att-line3">
{#if att.item_title && att.collection_slug}
in
{#if att.item_deleted}
<!-- Parent item is soft-deleted: render the title without
a link so the user knows the link target would 404. -->
<span class="deleted-item">[[{att.item_title}]]</span>
<span class="deleted-tag">deleted</span>
{:else}
<a href={itemHref(att)}>[[{att.item_title}]]</a>
{/if}
{:else}
<span class="unattached-tag">Unattached</span>
{/if}
</div>
</div>
<div class="att-actions">
<button
type="button"
class="btn btn-small btn-remove"
onclick={() => handleDelete(att)}
>
Delete
</button>
</div>
</div>
{/each}
</div>
<!-- Pager -->
<div class="pager">
<span>Showing {pageStart}{pageEnd} of {total}</span>
<div class="pager-btns">
<button
type="button"
class="btn btn-small"
disabled={!canPrev}
onclick={gotoPrev}
>
Prev
</button>
<button
type="button"
class="btn btn-small"
disabled={!canNext}
onclick={gotoNext}
>
Next
</button>
</div>
</div>
{/if}
{/if}
</div>
<style>
/* ── Local copies of the parent settings page primitives ────────────────
* Svelte 5 scopes styles to the component, so a child cannot reach the
* parent's `.card`/`.btn`/etc. These match the parent's visual conventions
* so the Storage tab feels native inside the settings page.
*/
.card {
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: var(--space-4);
}
.btn {
padding: var(--space-2) var(--space-4);
background: var(--bg-tertiary);
border: 1px solid var(--border);
border-radius: var(--radius);
font-size: 0.85em;
cursor: pointer;
color: var(--text-primary);
}
.btn:hover {
background: var(--bg-hover);
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-small {
padding: var(--space-1) var(--space-3);
font-size: 0.8em;
}
.btn-remove {
color: #ef4444;
border-color: transparent;
background: none;
}
.btn-remove:hover {
background: color-mix(in srgb, #ef4444 15%, transparent);
}
.role-select {
background: var(--bg-tertiary);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: var(--space-1) var(--space-2);
font-size: 0.8em;
color: var(--text-primary);
}
.empty-text {
color: var(--text-muted);
font-size: 0.9em;
text-align: center;
padding: var(--space-6);
}
.mono {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
}
/* ── Storage-specific styles ──────────────────────────────────────────── */
.storage-tab {
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.usage-bar-card {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.usage-line {
display: flex;
align-items: center;
gap: var(--space-3);
font-size: 1em;
flex-wrap: wrap;
}
.usage-unlimited {
color: var(--text-muted);
font-weight: 400;
}
.override-badge {
display: inline-block;
padding: 2px var(--space-2);
border-radius: var(--radius-sm);
background: color-mix(in srgb, var(--accent-blue) 15%, transparent);
color: var(--accent-blue);
font-size: 0.72em;
font-weight: 500;
letter-spacing: 0.02em;
}
.usage-bar {
height: 8px;
width: 100%;
background: var(--bg-tertiary);
border-radius: 4px;
overflow: hidden;
}
.usage-fill {
height: 100%;
background: var(--accent-blue);
transition: width 0.3s ease;
}
.usage-fill.warn {
background: color-mix(in srgb, #f59e0b 80%, transparent);
}
.usage-fill.crit {
background: color-mix(in srgb, #ef4444 85%, transparent);
}
.usage-subline {
font-size: 0.78em;
color: var(--text-muted);
}
.filter-row {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
align-items: center;
margin: var(--space-3) 0;
}
.filter-row label {
display: inline-flex;
align-items: center;
gap: var(--space-2);
font-size: 0.8em;
color: var(--text-secondary);
}
.att-list {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.att-row {
display: flex;
flex-direction: row;
gap: var(--space-3);
align-items: center;
padding: var(--space-2) var(--space-3);
}
.att-thumb {
flex-shrink: 0;
width: 48px;
height: 48px;
border-radius: var(--radius-sm);
background: var(--bg-tertiary);
display: flex;
align-items: center;
justify-content: center;
font-size: 1.4em;
overflow: hidden;
text-decoration: none;
}
.att-thumb img {
width: 100%;
height: 100%;
object-fit: cover;
}
.att-meta {
flex: 1;
min-width: 0;
}
.att-filename {
display: block;
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.att-line2,
.att-line3 {
font-size: 0.78em;
color: var(--text-muted);
}
.att-line2 {
margin-top: 2px;
}
.att-line3 {
margin-top: 2px;
}
.att-line3 a {
color: var(--accent-blue);
text-decoration: none;
}
.att-line3 a:hover {
text-decoration: underline;
}
.unattached-tag {
font-style: italic;
}
.deleted-item {
opacity: 0.7;
text-decoration: line-through;
}
.deleted-tag {
margin-left: var(--space-1);
font-size: 0.7em;
text-transform: uppercase;
color: #ef4444;
background: color-mix(in srgb, #ef4444 12%, transparent);
padding: 0 var(--space-1);
border-radius: var(--radius-sm);
}
.att-actions {
flex-shrink: 0;
}
.pager {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: var(--space-4);
font-size: 0.85em;
color: var(--text-muted);
}
.pager-btns {
display: flex;
gap: var(--space-2);
}
</style>