From b7a54ccd104319e91c5bce132ddd1a5d2e34f706 Mon Sep 17 00:00:00 2001 From: Till JS Date: Sat, 25 Apr 2026 12:29:53 +0200 Subject: [PATCH] feat(unlisted-sharing): QR code + per-link expiry picker (M8.5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SharedLinkControls now renders a lazy QR code (qrcode npm) and a datetime-local "Läuft ab" picker. Both stay in sync with the active URL — regenerating the link rebuilds the QR; clearing the expiry re-publishes with no `expiresAt`. Wired across all three unlisted collections: - Calendar: LocalEvent.unlistedExpiresAt + setUnlistedExpiry + preserve-on-refresh + clear-on-flip; both Workbench DetailView and EventDetailModal pass expiresAt+onExpiryChange to SharedLinkControls. - Library: same pattern in libraryEntriesStore + DetailView. - Places: same pattern in placesStore + DetailView. setVisibility clears any prior expiry so a flip-away-flip-back gets a fresh "never expires" link. refreshUnlistedSnapshot and regenerateUnlistedToken preserve the existing expiry so a content edit or token rotation never silently extends a link's lifetime. The qrcode dep ships as a regular `dependencies` entry on @mana/shared-privacy so any consuming app picks it up via the workspace. Note: an unrelated svelte-check error in writing/components/DraftCard ("draft" not assignable to DragType) exists from a parallel session and is not introduced by this commit. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../components/EventDetailModal.svelte | 6 + .../modules/calendar/stores/events.svelte.ts | 39 ++ .../web/src/lib/modules/calendar/types.ts | 5 + .../modules/calendar/views/DetailView.svelte | 6 + .../src/lib/modules/library/ListView.svelte | 1 + .../web/src/lib/modules/library/queries.ts | 1 + .../modules/library/stores/entries.svelte.ts | 40 ++ .../apps/web/src/lib/modules/library/types.ts | 4 + .../modules/library/views/DetailView.svelte | 6 + .../web/src/lib/modules/places/queries.ts | 1 + .../modules/places/stores/places.svelte.ts | 31 ++ .../apps/web/src/lib/modules/places/types.ts | 4 + .../modules/places/views/DetailView.svelte | 6 + packages/shared-privacy/package.json | 2 + .../src/SharedLinkControls.svelte | 87 ++++- pnpm-lock.yaml | 355 +++++++----------- 16 files changed, 379 insertions(+), 215 deletions(-) diff --git a/apps/mana/apps/web/src/lib/modules/calendar/components/EventDetailModal.svelte b/apps/mana/apps/web/src/lib/modules/calendar/components/EventDetailModal.svelte index 51c7fdd78..98fdeb981 100644 --- a/apps/mana/apps/web/src/lib/modules/calendar/components/EventDetailModal.svelte +++ b/apps/mana/apps/web/src/lib/modules/calendar/components/EventDetailModal.svelte @@ -46,6 +46,10 @@ await eventsStore.setVisibility(event.id, 'space'); } + async function handleExpiryChange(expiresAt: Date | null) { + await eventsStore.setUnlistedExpiry(event.id, expiresAt); + } + const shareUrl = $derived.by(() => { if (!event.unlistedToken) return ''; const origin = typeof window === 'undefined' ? 'https://mana.how' : window.location.origin; @@ -276,8 +280,10 @@ diff --git a/apps/mana/apps/web/src/lib/modules/calendar/stores/events.svelte.ts b/apps/mana/apps/web/src/lib/modules/calendar/stores/events.svelte.ts index b5dbb4723..490d4d0c9 100644 --- a/apps/mana/apps/web/src/lib/modules/calendar/stores/events.svelte.ts +++ b/apps/mana/apps/web/src/lib/modules/calendar/stores/events.svelte.ts @@ -436,6 +436,7 @@ export const eventsStore = { tagIds: data.tagIds || [], visibility: data.visibility ?? 'private', unlistedToken: data.unlistedToken ?? '', + unlistedExpiresAt: data.unlistedExpiresAt ?? null, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), blockType: 'event', @@ -523,6 +524,7 @@ export const eventsStore = { blob, }); patch.unlistedToken = token; + patch.unlistedExpiresAt = undefined; } else if (before === 'unlisted') { const jwt = await authStore.getValidToken(); if (jwt) { @@ -534,6 +536,7 @@ export const eventsStore = { }); } patch.unlistedToken = undefined; + patch.unlistedExpiresAt = undefined; } await db.table('events').update(id, patch); @@ -596,6 +599,39 @@ export const eventsStore = { } }, + /** + * Set or clear the unlisted-share expiry. Re-publishes the snapshot + * with the new `expiresAt`; mirrors the value locally so the + * SharedLinkControls picker shows the right state without a server + * round-trip. No-op if the event isn't currently 'unlisted'. + */ + async setUnlistedExpiry(id: string, expiresAt: Date | null) { + const existing = await db.table('events').get(id); + if (!existing || existing.visibility !== 'unlisted') return; + const jwt = await authStore.getValidToken(); + if (!jwt) return; + try { + const blob = await buildUnlistedBlob('events', id); + const spaceId = + (existing as unknown as { spaceId?: string }).spaceId ?? getActiveSpace()?.id ?? ''; + await publishUnlistedSnapshot({ + apiUrl: getManaApiUrl(), + jwt, + collection: 'events', + recordId: id, + spaceId, + blob, + expiresAt, + }); + await db.table('events').update(id, { + unlistedExpiresAt: expiresAt ? expiresAt.toISOString() : undefined, + updatedAt: new Date().toISOString(), + }); + } catch (e) { + console.error('[calendar/events] setUnlistedExpiry failed', e); + } + }, + /** * Re-publish the unlisted snapshot for an event. Called by * updateEvent/updateSingleInstance/etc. when the owning record is @@ -622,6 +658,9 @@ export const eventsStore = { recordId: id, spaceId, blob, + // Preserve any existing expiry so a content edit doesn't + // silently extend the link's lifetime. + expiresAt: existing.unlistedExpiresAt ? new Date(existing.unlistedExpiresAt) : undefined, }); } catch (e) { console.error('[calendar/events] refreshUnlistedSnapshot failed', e); diff --git a/apps/mana/apps/web/src/lib/modules/calendar/types.ts b/apps/mana/apps/web/src/lib/modules/calendar/types.ts index 474902f22..6529227b4 100644 --- a/apps/mana/apps/web/src/lib/modules/calendar/types.ts +++ b/apps/mana/apps/web/src/lib/modules/calendar/types.ts @@ -30,6 +30,8 @@ export interface LocalEvent extends BaseRecord { visibilityChangedAt?: string; visibilityChangedBy?: string; unlistedToken?: string; + /** Local mirror of the server-side unlisted-snapshot expiry. */ + unlistedExpiresAt?: string; } export type CalendarViewType = 'week' | 'month' | 'agenda'; @@ -60,6 +62,8 @@ export interface CalendarEvent { * to know whether to render the share-link controls). */ unlistedToken: string; + /** ISO expiry for the active unlisted-share, null when never expires. */ + unlistedExpiresAt: string | null; createdAt: string; updatedAt: string; // TimeBlock metadata (for universal calendar view) @@ -114,6 +118,7 @@ export function timeBlockToCalendarEvent( // they stay invisible on the website (public requires explicit opt-in). visibility: eventData?.visibility ?? 'space', unlistedToken: eventData?.unlistedToken ?? '', + unlistedExpiresAt: eventData?.unlistedExpiresAt ?? null, createdAt: block.createdAt, updatedAt: block.updatedAt, blockType: block.type, diff --git a/apps/mana/apps/web/src/lib/modules/calendar/views/DetailView.svelte b/apps/mana/apps/web/src/lib/modules/calendar/views/DetailView.svelte index 8705c5e82..244fd80ce 100644 --- a/apps/mana/apps/web/src/lib/modules/calendar/views/DetailView.svelte +++ b/apps/mana/apps/web/src/lib/modules/calendar/views/DetailView.svelte @@ -111,6 +111,10 @@ await eventsStore.setVisibility(eventId, 'space'); } + async function handleExpiryChange(expiresAt: Date | null) { + await eventsStore.setUnlistedExpiry(eventId, expiresAt); + } + const shareUrl = $derived.by(() => { const token = detail.entity?.unlistedToken; if (!token) return ''; @@ -158,8 +162,10 @@ {/if} diff --git a/apps/mana/apps/web/src/lib/modules/library/ListView.svelte b/apps/mana/apps/web/src/lib/modules/library/ListView.svelte index f6d57dceb..8d8f9c86e 100644 --- a/apps/mana/apps/web/src/lib/modules/library/ListView.svelte +++ b/apps/mana/apps/web/src/lib/modules/library/ListView.svelte @@ -111,6 +111,7 @@ : { kind: 'comic' }, visibility: 'private', unlistedToken: '', + unlistedExpiresAt: null, createdAt: '', updatedAt: '', id: '', diff --git a/apps/mana/apps/web/src/lib/modules/library/queries.ts b/apps/mana/apps/web/src/lib/modules/library/queries.ts index 7107f1d4f..43c560d40 100644 --- a/apps/mana/apps/web/src/lib/modules/library/queries.ts +++ b/apps/mana/apps/web/src/lib/modules/library/queries.ts @@ -37,6 +37,7 @@ export function toLibraryEntry(local: LocalLibraryEntry): LibraryEntry { // space-type-aware default at create time in entries.svelte.ts. visibility: local.visibility ?? 'space', unlistedToken: local.unlistedToken ?? '', + unlistedExpiresAt: local.unlistedExpiresAt ?? null, createdAt: local.createdAt ?? now, updatedAt: local.updatedAt ?? now, }; diff --git a/apps/mana/apps/web/src/lib/modules/library/stores/entries.svelte.ts b/apps/mana/apps/web/src/lib/modules/library/stores/entries.svelte.ts index 171277999..182f00c2a 100644 --- a/apps/mana/apps/web/src/lib/modules/library/stores/entries.svelte.ts +++ b/apps/mana/apps/web/src/lib/modules/library/stores/entries.svelte.ts @@ -259,6 +259,7 @@ export const libraryEntriesStore = { blob, }); patch.unlistedToken = token; + patch.unlistedExpiresAt = undefined; } else if (before === 'unlisted') { const jwt = await authStore.getValidToken(); if (jwt) { @@ -270,6 +271,7 @@ export const libraryEntriesStore = { }); } patch.unlistedToken = undefined; + patch.unlistedExpiresAt = undefined; } await libraryEntryTable.update(id, patch); @@ -309,6 +311,9 @@ export const libraryEntriesStore = { recordId: id, spaceId, blob, + // Preserve any existing expiry — regenerate is about leaking + // the URL, not extending the lifetime. + expiresAt: existing.unlistedExpiresAt ? new Date(existing.unlistedExpiresAt) : undefined, }); await libraryEntryTable.update(id, { unlistedToken: token, @@ -321,6 +326,38 @@ export const libraryEntriesStore = { } }, + /** + * Set or clear the unlisted-share expiry. Mirrors + * eventsStore.setUnlistedExpiry — re-publishes with the new expiry + * and stores it locally so the picker stays in sync. + */ + async setUnlistedExpiry(id: string, expiresAt: Date | null) { + const existing = await libraryEntryTable.get(id); + if (!existing || existing.visibility !== 'unlisted') return; + const jwt = await authStore.getValidToken(); + if (!jwt) return; + try { + const blob = await buildUnlistedBlob('libraryEntries', id); + const spaceId = + (existing as unknown as { spaceId?: string }).spaceId ?? getActiveSpace()?.id ?? ''; + await publishUnlistedSnapshot({ + apiUrl: getManaApiUrl(), + jwt, + collection: 'libraryEntries', + recordId: id, + spaceId, + blob, + expiresAt, + }); + await libraryEntryTable.update(id, { + unlistedExpiresAt: expiresAt ? expiresAt.toISOString() : undefined, + updatedAt: new Date().toISOString(), + }); + } catch (e) { + console.error('[library] setUnlistedExpiry failed', e); + } + }, + /** * Re-publish unlisted snapshot when whitelist fields change. Called * fire-and-forget after updateEntry/setStatus/rate. No-op if the @@ -342,6 +379,9 @@ export const libraryEntriesStore = { recordId: id, spaceId, blob, + // Preserve any existing expiry so a content edit doesn't + // silently extend the link's lifetime. + expiresAt: existing.unlistedExpiresAt ? new Date(existing.unlistedExpiresAt) : undefined, }); } catch (e) { console.error('[library] refreshUnlistedSnapshot failed', e); diff --git a/apps/mana/apps/web/src/lib/modules/library/types.ts b/apps/mana/apps/web/src/lib/modules/library/types.ts index 79b9e52df..3e7a253f8 100644 --- a/apps/mana/apps/web/src/lib/modules/library/types.ts +++ b/apps/mana/apps/web/src/lib/modules/library/types.ts @@ -102,6 +102,8 @@ export interface LocalLibraryEntry extends BaseRecord { * when visibility moves back to anything else. */ unlistedToken?: string; + /** ISO timestamp when the unlisted snapshot expires; absent = never. */ + unlistedExpiresAt?: string; } // ─── Domain Type (plaintext, for UI) ───────────────────── @@ -129,6 +131,8 @@ export interface LibraryEntry { visibility: VisibilityLevel; /** Server-issued share token. Empty when not 'unlisted'. */ unlistedToken: string; + /** ISO timestamp when the unlisted snapshot expires, or null = never. */ + unlistedExpiresAt: string | null; createdAt: string; updatedAt: string; } diff --git a/apps/mana/apps/web/src/lib/modules/library/views/DetailView.svelte b/apps/mana/apps/web/src/lib/modules/library/views/DetailView.svelte index 48d29f1e4..e860704f2 100644 --- a/apps/mana/apps/web/src/lib/modules/library/views/DetailView.svelte +++ b/apps/mana/apps/web/src/lib/modules/library/views/DetailView.svelte @@ -28,6 +28,10 @@ await libraryEntriesStore.setVisibility(entry.id, 'space'); } + async function onExpiryChange(expiresAt: Date | null) { + await libraryEntriesStore.setUnlistedExpiry(entry.id, expiresAt); + } + const shareUrl = $derived.by(() => { if (!entry.unlistedToken) return ''; const origin = typeof window === 'undefined' ? 'https://mana.how' : window.location.origin; @@ -167,8 +171,10 @@ {/if} diff --git a/apps/mana/apps/web/src/lib/modules/places/queries.ts b/apps/mana/apps/web/src/lib/modules/places/queries.ts index e66eaa013..b1e4dd9f6 100644 --- a/apps/mana/apps/web/src/lib/modules/places/queries.ts +++ b/apps/mana/apps/web/src/lib/modules/places/queries.ts @@ -26,6 +26,7 @@ export function toPlace(local: LocalPlace): Place { tagIds: local.tagIds ?? [], visibility: local.visibility ?? 'space', unlistedToken: local.unlistedToken ?? '', + unlistedExpiresAt: local.unlistedExpiresAt ?? null, createdAt: local.createdAt ?? new Date().toISOString(), updatedAt: local.updatedAt ?? new Date().toISOString(), }; diff --git a/apps/mana/apps/web/src/lib/modules/places/stores/places.svelte.ts b/apps/mana/apps/web/src/lib/modules/places/stores/places.svelte.ts index 9cdbe4124..499f866e7 100644 --- a/apps/mana/apps/web/src/lib/modules/places/stores/places.svelte.ts +++ b/apps/mana/apps/web/src/lib/modules/places/stores/places.svelte.ts @@ -201,6 +201,7 @@ export const placesStore = { blob, }); patch.unlistedToken = token; + patch.unlistedExpiresAt = undefined; } else if (before === 'unlisted') { const jwt = await authStore.getValidToken(); if (jwt) { @@ -212,6 +213,7 @@ export const placesStore = { }); } patch.unlistedToken = undefined; + patch.unlistedExpiresAt = undefined; } await placeTable.update(id, patch); @@ -246,6 +248,7 @@ export const placesStore = { recordId: id, spaceId, blob, + expiresAt: existing.unlistedExpiresAt ? new Date(existing.unlistedExpiresAt) : undefined, }); await placeTable.update(id, { unlistedToken: token, @@ -258,6 +261,33 @@ export const placesStore = { } }, + async setUnlistedExpiry(id: string, expiresAt: Date | null) { + const existing = await placeTable.get(id); + if (!existing || existing.visibility !== 'unlisted') return; + const jwt = await authStore.getValidToken(); + if (!jwt) return; + try { + const blob = await buildUnlistedBlob('places', id); + const spaceId = + (existing as unknown as { spaceId?: string }).spaceId ?? getActiveSpace()?.id ?? ''; + await publishUnlistedSnapshot({ + apiUrl: getManaApiUrl(), + jwt, + collection: 'places', + recordId: id, + spaceId, + blob, + expiresAt, + }); + await placeTable.update(id, { + unlistedExpiresAt: expiresAt ? expiresAt.toISOString() : undefined, + updatedAt: new Date().toISOString(), + }); + } catch (e) { + console.error('[places] setUnlistedExpiry failed', e); + } + }, + async refreshUnlistedSnapshot(id: string) { const existing = await placeTable.get(id); if (!existing || existing.visibility !== 'unlisted') return; @@ -274,6 +304,7 @@ export const placesStore = { recordId: id, spaceId, blob, + expiresAt: existing.unlistedExpiresAt ? new Date(existing.unlistedExpiresAt) : undefined, }); } catch (e) { console.error('[places] refreshUnlistedSnapshot failed', e); diff --git a/apps/mana/apps/web/src/lib/modules/places/types.ts b/apps/mana/apps/web/src/lib/modules/places/types.ts index e23e32368..de29dc41d 100644 --- a/apps/mana/apps/web/src/lib/modules/places/types.ts +++ b/apps/mana/apps/web/src/lib/modules/places/types.ts @@ -23,6 +23,8 @@ export interface LocalPlace extends BaseRecord { visibilityChangedAt?: string; visibilityChangedBy?: string; unlistedToken?: string; + /** ISO timestamp when the unlisted snapshot expires; absent = never. */ + unlistedExpiresAt?: string; } export interface LocalLocationLog extends BaseRecord { @@ -54,6 +56,8 @@ export interface Place { visibility: VisibilityLevel; /** Server-issued share token. Empty when not 'unlisted'. */ unlistedToken: string; + /** ISO timestamp when the unlisted snapshot expires, or null = never. */ + unlistedExpiresAt: string | null; createdAt: string; updatedAt: string; } diff --git a/apps/mana/apps/web/src/lib/modules/places/views/DetailView.svelte b/apps/mana/apps/web/src/lib/modules/places/views/DetailView.svelte index 8e6dbec4c..0cf7e241d 100644 --- a/apps/mana/apps/web/src/lib/modules/places/views/DetailView.svelte +++ b/apps/mana/apps/web/src/lib/modules/places/views/DetailView.svelte @@ -171,6 +171,10 @@ await placesStore.setVisibility(placeId, 'space'); } + async function handleExpiryChange(expiresAt: Date | null) { + await placesStore.setUnlistedExpiry(placeId, expiresAt); + } + const shareUrl = $derived.by(() => { const token = detail.entity?.unlistedToken; if (!token) return ''; @@ -261,8 +265,10 @@ {/if} diff --git a/packages/shared-privacy/package.json b/packages/shared-privacy/package.json index f91b61584..2ee3144b1 100644 --- a/packages/shared-privacy/package.json +++ b/packages/shared-privacy/package.json @@ -26,9 +26,11 @@ }, "dependencies": { "@mana/shared-icons": "workspace:*", + "qrcode": "^1.5.4", "zod": "^3.25.76" }, "devDependencies": { + "@types/qrcode": "^1.5.5", "svelte": "^5.0.0", "svelte-check": "^4.0.0", "typescript": "^5.7.3", diff --git a/packages/shared-privacy/src/SharedLinkControls.svelte b/packages/shared-privacy/src/SharedLinkControls.svelte index 0cac6fdfb..d9e6e89e5 100644 --- a/packages/shared-privacy/src/SharedLinkControls.svelte +++ b/packages/shared-privacy/src/SharedLinkControls.svelte @@ -4,16 +4,14 @@ Shown below the VisibilityPicker in a module's DetailView when `visibility === 'unlisted'` and a token exists. Dumb — owner (the module DetailView) passes the token + handlers; this component only - renders the URL, manages copy-to-clipboard, and dispatches - regenerate/revoke/expiry actions. - - QR-code rendering is a later polish (M8.5) — currently stubbed as - a button that disables itself. Expiry-picker is present but - minimal — datetime-local input, no fancy picker. + renders the URL, manages copy-to-clipboard, QR-code display, and + dispatches regenerate/revoke/expiry actions. See docs/plans/unlisted-sharing.md §7. -->