diff --git a/apps/mana/apps/web/src/lib/data/unlisted/resolvers.ts b/apps/mana/apps/web/src/lib/data/unlisted/resolvers.ts index 5a5ac04e3..42fdd5f16 100644 --- a/apps/mana/apps/web/src/lib/data/unlisted/resolvers.ts +++ b/apps/mana/apps/web/src/lib/data/unlisted/resolvers.ts @@ -23,6 +23,7 @@ import type { LocalPlace } from '$lib/modules/places/types'; import type { LocalTimeBlock } from '$lib/data/time-blocks/types'; import type { LocalAugurEntry } from '$lib/modules/augur/types'; import type { LocalLast } from '$lib/modules/lasts/types'; +import type { LocalForm } from '$lib/modules/forms/types'; export class UnsupportedCollectionError extends Error { constructor(collection: string) { @@ -57,6 +58,8 @@ export async function buildUnlistedBlob( return buildAugurEntryBlob(recordId); case 'lasts': return buildLastBlob(recordId); + case 'forms': + return buildFormBlob(recordId); default: throw new UnsupportedCollectionError(collection); } @@ -268,3 +271,59 @@ async function buildLastBlob(recordId: string): Promise> wouldReclaim: decrypted.wouldReclaim ?? null, }; } + +/** + * Form → public-submit snapshot blob. + * + * Whitelist: title, description, fields, branching, + * settings.submitButtonLabel, settings.successMessage. The blob is + * what an unauthenticated submitter sees when they hit + * `/forms/` — it's the form schema plus the two pieces of + * settings copy that surface in the public UI. + * + * Hard-blocked from the snapshot: + * - `responseCount` — internal counter, leaks scale + * - `settings.requireEmail`/`allowMultipleSubmissions`/`anonymous`/ + * `zkMode`/`autoSync`/`responseLimit`/`closedAt` — these are + * authoritative server-side checks in the public-submit endpoint + * (M3.b) and should not be discoverable via the public blob; + * leaking them invites enumeration probes. + * - `responsesPublic` — explicitly omitted; the public view shows + * the form, not the answers. M-future will need a separate share + * token + endpoint for response-aggregate publication. + * + * Refuses to serialise closed forms (status === 'closed') so revoked + * tokens can't be brought back via a snapshot replay. Draft forms are + * also refused — only `published` forms have a public surface. + */ +async function buildFormBlob(recordId: string): Promise> { + const raw = await db.table('forms').get(recordId); + if (!raw || raw.deletedAt) { + throw new RecordNotFoundError('forms', recordId); + } + if (raw.status !== 'published') { + throw new RecordNotFoundError('forms', recordId); + } + + const decrypted = (await decryptRecord('forms', { ...raw })) as LocalForm; + + const settings = decrypted.settings ?? { + submitButtonLabel: 'Senden', + successMessage: 'Danke! Deine Antwort wurde übermittelt.', + allowMultipleSubmissions: false, + requireEmail: false, + anonymous: false, + zkMode: false, + }; + + return { + title: decrypted.title, + description: decrypted.description ?? null, + fields: decrypted.fields ?? [], + branching: decrypted.branching ?? [], + settings: { + submitButtonLabel: settings.submitButtonLabel, + successMessage: settings.successMessage, + }, + }; +} diff --git a/apps/mana/apps/web/src/lib/i18n/locales/forms/de.json b/apps/mana/apps/web/src/lib/i18n/locales/forms/de.json index 60c57b98b..1d7ba0c7d 100644 --- a/apps/mana/apps/web/src/lib/i18n/locales/forms/de.json +++ b/apps/mana/apps/web/src/lib/i18n/locales/forms/de.json @@ -71,6 +71,10 @@ "allowMultiple": "Mehrere Antworten pro Person erlauben", "anonymous": "Anonym — Submitter-Daten nicht speichern" }, + "visibility": { + "title": "Sichtbarkeit & Teilen", + "publishHint": "Setze den Status auf \"Veröffentlicht\", um zu teilen." + }, "viewResponses": "Antworten ({n})" }, "responses": { diff --git a/apps/mana/apps/web/src/lib/i18n/locales/forms/en.json b/apps/mana/apps/web/src/lib/i18n/locales/forms/en.json index 7e2eb0feb..806c1514c 100644 --- a/apps/mana/apps/web/src/lib/i18n/locales/forms/en.json +++ b/apps/mana/apps/web/src/lib/i18n/locales/forms/en.json @@ -71,6 +71,10 @@ "allowMultiple": "Allow multiple submissions per person", "anonymous": "Anonymous — don't store submitter data" }, + "visibility": { + "title": "Visibility & Sharing", + "publishHint": "Set the status to \"Published\" to share." + }, "viewResponses": "Responses ({n})" }, "responses": { diff --git a/apps/mana/apps/web/src/lib/i18n/locales/forms/es.json b/apps/mana/apps/web/src/lib/i18n/locales/forms/es.json index 3b7bad6b4..f88f37d75 100644 --- a/apps/mana/apps/web/src/lib/i18n/locales/forms/es.json +++ b/apps/mana/apps/web/src/lib/i18n/locales/forms/es.json @@ -71,6 +71,10 @@ "allowMultiple": "Permitir varias respuestas por persona", "anonymous": "Anónimo — no guardar datos del remitente" }, + "visibility": { + "title": "Visibilidad y compartir", + "publishHint": "Pon el estado en \"Publicado\" para compartir." + }, "viewResponses": "Respuestas ({n})" }, "responses": { diff --git a/apps/mana/apps/web/src/lib/i18n/locales/forms/fr.json b/apps/mana/apps/web/src/lib/i18n/locales/forms/fr.json index bae65617e..8ba83876a 100644 --- a/apps/mana/apps/web/src/lib/i18n/locales/forms/fr.json +++ b/apps/mana/apps/web/src/lib/i18n/locales/forms/fr.json @@ -71,6 +71,10 @@ "allowMultiple": "Autoriser plusieurs réponses par personne", "anonymous": "Anonyme — ne pas conserver les données du destinataire" }, + "visibility": { + "title": "Visibilité et partage", + "publishHint": "Mets le statut sur \"Publié\" pour partager." + }, "viewResponses": "Réponses ({n})" }, "responses": { diff --git a/apps/mana/apps/web/src/lib/i18n/locales/forms/it.json b/apps/mana/apps/web/src/lib/i18n/locales/forms/it.json index 8ee1824be..78a6b352e 100644 --- a/apps/mana/apps/web/src/lib/i18n/locales/forms/it.json +++ b/apps/mana/apps/web/src/lib/i18n/locales/forms/it.json @@ -71,6 +71,10 @@ "allowMultiple": "Permetti più risposte per persona", "anonymous": "Anonimo — non memorizzare i dati del mittente" }, + "visibility": { + "title": "Visibilità e condivisione", + "publishHint": "Imposta lo stato su \"Pubblicato\" per condividere." + }, "viewResponses": "Risposte ({n})" }, "responses": { diff --git a/apps/mana/apps/web/src/lib/modules/forms/SharedFormView.svelte b/apps/mana/apps/web/src/lib/modules/forms/SharedFormView.svelte new file mode 100644 index 000000000..895419710 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/forms/SharedFormView.svelte @@ -0,0 +1,477 @@ + + + +
+
+

{form.title}

+ {#if form.description} +

{form.description}

+ {/if} +
+ + {#if submitted} +
+

{form.settings.successMessage}

+

+ Hinweis: Public-Submit ist im aktuellen Mana-Build noch nicht serverseitig verdrahtet — + deine Antwort wurde nicht übermittelt. +

+
+ {:else} +
+ {#each visibleFields as field (field.id)} +
+ {#if field.type === 'section'} +
+

{field.label}

+ {:else if field.type === 'consent'} + + {:else} + + {#if field.helpText} +

{field.helpText}

+ {/if} + + {#if field.type === 'short_text'} + setAnswer(field.id, (e.currentTarget as HTMLInputElement).value)} + /> + {:else if field.type === 'long_text'} + + {:else if field.type === 'email'} + setAnswer(field.id, (e.currentTarget as HTMLInputElement).value)} + /> + {:else if field.type === 'number'} + { + const v = (e.currentTarget as HTMLInputElement).value; + setAnswer(field.id, v === '' ? null : Number(v)); + }} + /> + {:else if field.type === 'date'} + setAnswer(field.id, (e.currentTarget as HTMLInputElement).value)} + /> + {:else if field.type === 'yes_no'} +
+ + +
+ {:else if field.type === 'rating'} +
+ {#each ratingScale(field) as n} + + {/each} +
+ {:else if field.type === 'single_choice'} +
+ {#each field.options ?? [] as opt (opt.id)} + + {/each} +
+ {:else if field.type === 'multi_choice'} +
+ {#each field.options ?? [] as opt (opt.id)} + {@const checked = ((answers[field.id] as string[] | undefined) ?? []).includes( + opt.id + )} + + {/each} +
+ {/if} + {/if} +
+ {/each} + +
+ +

+ Public-Submit landet im nächsten Mana-Schritt — vorerst zeigt der Klick nur die + Bestätigungsnachricht. +

+ {#if expiresAt} +

Dieser Link läuft am {fmtExpiry(expiresAt)} ab.

+ {/if} +
+
+ {/if} + + +
+ + diff --git a/apps/mana/apps/web/src/lib/modules/forms/stores/forms.svelte.ts b/apps/mana/apps/web/src/lib/modules/forms/stores/forms.svelte.ts index 27749d522..4d2148a4f 100644 --- a/apps/mana/apps/web/src/lib/modules/forms/stores/forms.svelte.ts +++ b/apps/mana/apps/web/src/lib/modules/forms/stores/forms.svelte.ts @@ -3,6 +3,16 @@ import { toForm } from '../queries'; import { encryptRecord } from '$lib/data/crypto'; import { DEFAULT_FORM_SETTINGS } from '../types'; import type { BranchingRule, Form, FormField, FormSettings, FormStatus, LocalForm } from '../types'; +import { + publishUnlistedSnapshot, + revokeUnlistedSnapshot, + type VisibilityLevel, +} from '@mana/shared-privacy'; +import { buildUnlistedBlob } from '$lib/data/unlisted/resolvers'; +import { authStore } from '$lib/stores/auth.svelte'; +import { getManaApiUrl } from '$lib/api/config'; +import { getActiveSpace } from '$lib/data/scope'; +import { getEffectiveUserId } from '$lib/data/current-user'; function nowIso(): string { return new Date().toISOString(); @@ -96,4 +106,134 @@ export const formsStore = { await encryptRecord('forms', diff); await formTable.update(id, diff); }, + + // ── Visibility / Unlisted-Sharing (M4b) ─────────────────── + + /** + * Change a form's visibility level. Transitions to `unlisted` publish + * the public snapshot blob and assign a share token; transitions away + * revoke it. Only `published` forms can go public — the resolver + * defends this too, so a draft → unlisted attempt fails fast here + * with a clear message. + */ + async setVisibility(id: string, next: VisibilityLevel) { + const existing = await formTable.get(id); + if (!existing) throw new Error(`Form ${id} not found`); + const before: VisibilityLevel = existing.visibility ?? 'private'; + if (before === next) return; + + if (next === 'unlisted' && existing.status !== 'published') { + throw new Error( + 'Nur veröffentlichte Formulare können geteilt werden. Setze erst den Status auf "Veröffentlicht".' + ); + } + + const now = nowIso(); + const patch: Partial = { + visibility: next, + visibilityChangedAt: now, + visibilityChangedBy: getEffectiveUserId() ?? undefined, + }; + + if (next === 'unlisted') { + const jwt = await authStore.getValidToken(); + if (!jwt) throw new Error('Nicht eingeloggt — Share-Link kann nicht erzeugt werden.'); + const blob = await buildUnlistedBlob('forms', id); + const spaceId = + (existing as unknown as { spaceId?: string }).spaceId ?? getActiveSpace()?.id ?? ''; + const { token } = await publishUnlistedSnapshot({ + apiUrl: getManaApiUrl(), + jwt, + collection: 'forms', + recordId: id, + spaceId, + blob, + }); + patch.unlistedToken = token; + patch.unlistedExpiresAt = null; + } else if (before === 'unlisted') { + const jwt = await authStore.getValidToken(); + if (jwt) { + try { + await revokeUnlistedSnapshot({ + apiUrl: getManaApiUrl(), + jwt, + collection: 'forms', + recordId: id, + }); + } catch { + // Server may already have GC'd the row — local flip still correct. + } + } + patch.unlistedToken = ''; + patch.unlistedExpiresAt = null; + } + + await formTable.update(id, patch); + }, + + /** + * Rotate the share token. Old URL stops working immediately, new one + * carries the same expiry (if any). Useful when the link leaked. + */ + async regenerateUnlistedToken(id: string): Promise { + const existing = await formTable.get(id); + if (!existing || existing.visibility !== 'unlisted') return null; + const jwt = await authStore.getValidToken(); + if (!jwt) return null; + + try { + await revokeUnlistedSnapshot({ + apiUrl: getManaApiUrl(), + jwt, + collection: 'forms', + recordId: id, + }); + } catch { + // Defensive — proceed even if server GC'd the snapshot. + } + + const blob = await buildUnlistedBlob('forms', id); + const spaceId = + (existing as unknown as { spaceId?: string }).spaceId ?? getActiveSpace()?.id ?? ''; + const { token } = await publishUnlistedSnapshot({ + apiUrl: getManaApiUrl(), + jwt, + collection: 'forms', + recordId: id, + spaceId, + blob, + expiresAt: existing.unlistedExpiresAt ? new Date(existing.unlistedExpiresAt) : undefined, + }); + await formTable.update(id, { unlistedToken: token }); + return token; + }, + + /** + * Update the auto-revoke deadline. `null` = never expires. Server + * re-publishes the same blob with the new TTL. + */ + async setUnlistedExpiry(id: string, expiresAt: Date | null) { + const existing = await formTable.get(id); + if (!existing || existing.visibility !== 'unlisted') return; + const jwt = await authStore.getValidToken(); + if (!jwt) return; + + const blob = await buildUnlistedBlob('forms', id); + const spaceId = + (existing as unknown as { spaceId?: string }).spaceId ?? getActiveSpace()?.id ?? ''; + const { token } = await publishUnlistedSnapshot({ + apiUrl: getManaApiUrl(), + jwt, + collection: 'forms', + recordId: id, + spaceId, + blob, + expiresAt: expiresAt ?? undefined, + }); + await formTable.update(id, { + unlistedToken: token, + unlistedExpiresAt: expiresAt ? expiresAt.toISOString() : null, + }); + }, }; diff --git a/apps/mana/apps/web/src/lib/modules/forms/views/BuilderView.svelte b/apps/mana/apps/web/src/lib/modules/forms/views/BuilderView.svelte index f43da46ac..47c2b8324 100644 --- a/apps/mana/apps/web/src/lib/modules/forms/views/BuilderView.svelte +++ b/apps/mana/apps/web/src/lib/modules/forms/views/BuilderView.svelte @@ -19,6 +19,12 @@ import FieldPalette from '../components/FieldPalette.svelte'; import SettingsPanel from '../components/SettingsPanel.svelte'; import BranchingEditor from '../components/BranchingEditor.svelte'; + import { + VisibilityPicker, + SharedLinkControls, + buildShareUrl, + type VisibilityLevel, + } from '@mana/shared-privacy'; let { entry }: { entry: Form } = $props(); @@ -113,6 +119,36 @@ await formsStore.updateBranching(entry.id, next); } + // ── Visibility / Share-Link ──────────────────────────── + let visibilityError = $state(null); + + async function onVisibilityChange(next: VisibilityLevel) { + visibilityError = null; + try { + await formsStore.setVisibility(entry.id, next); + } catch (err) { + visibilityError = err instanceof Error ? err.message : String(err); + } + } + + async function handleRegenerate() { + await formsStore.regenerateUnlistedToken(entry.id); + } + + async function handleRevoke() { + await formsStore.setVisibility(entry.id, 'private'); + } + + async function handleExpiryChange(expiresAt: Date | null) { + await formsStore.setUnlistedExpiry(entry.id, expiresAt); + } + + const shareUrl = $derived.by(() => { + if (!entry.unlistedToken) return ''; + const origin = typeof window === 'undefined' ? 'https://mana.how' : window.location.origin; + return buildShareUrl(origin, entry.unlistedToken); + }); + async function setStatus(status: FormStatus) { await formsStore.setStatus(entry.id, status); } @@ -226,6 +262,35 @@ +
+
+

+ {$_('forms.builder.visibility.title', { default: 'Sichtbarkeit & Teilen' })} +

+ {#if entry.status !== 'published' && entry.visibility !== 'unlisted'} + + {$_('forms.builder.visibility.publishHint', { + default: 'Setze den Status auf "Veröffentlicht", um zu teilen.', + })} + + {/if} +
+ + {#if visibilityError} +

{visibilityError}

+ {/if} + {#if entry.visibility === 'unlisted' && entry.unlistedToken && shareUrl} + + {/if} +
+
@@ -372,12 +437,47 @@ .fields-section, .settings-section, - .branching-section { + .branching-section, + .visibility-section { display: flex; flex-direction: column; gap: 0.625rem; } + .visibility-section { + padding: 0.875rem; + background: rgb(255 255 255 / 0.03); + border: 1px solid rgb(255 255 255 / 0.06); + border-radius: 0.5rem; + } + + .vis-header { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 0.5rem; + flex-wrap: wrap; + } + + .panel-title { + margin: 0; + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.06em; + color: rgb(255 255 255 / 0.5); + } + + .vis-hint { + font-size: 0.75rem; + color: rgb(255 255 255 / 0.45); + } + + .vis-error { + margin: 0; + font-size: 0.8125rem; + color: rgb(252 165 165); + } + .section-header { display: flex; align-items: baseline; diff --git a/apps/mana/apps/web/src/routes/share/[token]/+page.svelte b/apps/mana/apps/web/src/routes/share/[token]/+page.svelte index 0acd48a93..91c88e679 100644 --- a/apps/mana/apps/web/src/routes/share/[token]/+page.svelte +++ b/apps/mana/apps/web/src/routes/share/[token]/+page.svelte @@ -9,6 +9,7 @@ import SharedPlaceView from '$lib/modules/places/SharedPlaceView.svelte'; import SharedAugurEntryView from '$lib/modules/augur/SharedAugurEntryView.svelte'; import SharedLastView from '$lib/modules/lasts/SharedLastView.svelte'; + import SharedFormView from '$lib/modules/forms/SharedFormView.svelte'; import type { PageData } from './$types'; let { data }: { data: PageData } = $props(); @@ -24,6 +25,8 @@ {:else if data.collection === 'lasts'} +{:else if data.collection === 'forms'} + {:else}

Unbekannter Link-Typ