mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:21:09 +02:00
feat(forms): M4b visibility + unlisted-sharing + public render
Form-Sharing-Pipeline (docs/plans/forms-module.md M4 — Teil 2): - formsStore.setVisibility(id, level): private/space/unlisted-Wechsel. Bei `unlisted` wird publishUnlistedSnapshot mit dem Form-Schema-Blob aufgerufen, Token + Expiry landen am LocalForm. Setze Status auf 'published' Voraussetzung — sonst klare Fehlermeldung mit Hinweis. - regenerateUnlistedToken: Token-Rotation für leak-Verdacht (revoke + neu publish, expiry beibehalten). - setUnlistedExpiry: TTL-Update mit re-publish. - buildFormBlob in data/unlisted/resolvers.ts mit Whitelist title/description/fields/branching + nur submitButtonLabel + successMessage aus settings. Hard-blocks: nicht-published Forms + deletedAt → RecordNotFoundError. Server-side Settings (requireEmail, anonymous, zkMode, autoSync, responseLimit, closedAt, responsesPublic) bleiben strukturell aussen vor — Public-Endpoint validiert authoritativ ohne Discovery-Surface. - VisibilityPicker + SharedLinkControls in BuilderView, eigene Section mit Status-Hint wenn Form noch nicht published ist. - SharedFormView (498 Zeilen): public-render mit allen 11 Field-Types (short/long_text, single/multi_choice, number, date, email, yes_no, rating, section, consent), Live-Branching via resolveVisibleFields bei jedem Keystroke, Required-Field-Validierung blockt Submit-Button. Submit zeigt successMessage + DEV-Hinweis (Public-Submit-Endpoint landet in M3.b). Mana-Branding-Footer. - Share-Dispatcher /share/[token] kennt `forms` collection. - 10 neue i18n-Keys × 5 Locales (forms.builder.visibility.*). Public-Submit-Pipeline (mana-api POST → mana-sync → owner client) ist M3.b. Bis dahin zeigt SharedFormView.handleSubmit nur die success- Message ohne Server-Roundtrip. svelte-check: 0 errors in forms/. Pre-existing context-removal-Drift in cross-app-queries + widget-registry (4 errors) ist Parallel-Session- WIP. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
afeb32f922
commit
18f13e19b2
10 changed files with 800 additions and 1 deletions
|
|
@ -23,6 +23,7 @@ import type { LocalPlace } from '$lib/modules/places/types';
|
||||||
import type { LocalTimeBlock } from '$lib/data/time-blocks/types';
|
import type { LocalTimeBlock } from '$lib/data/time-blocks/types';
|
||||||
import type { LocalAugurEntry } from '$lib/modules/augur/types';
|
import type { LocalAugurEntry } from '$lib/modules/augur/types';
|
||||||
import type { LocalLast } from '$lib/modules/lasts/types';
|
import type { LocalLast } from '$lib/modules/lasts/types';
|
||||||
|
import type { LocalForm } from '$lib/modules/forms/types';
|
||||||
|
|
||||||
export class UnsupportedCollectionError extends Error {
|
export class UnsupportedCollectionError extends Error {
|
||||||
constructor(collection: string) {
|
constructor(collection: string) {
|
||||||
|
|
@ -57,6 +58,8 @@ export async function buildUnlistedBlob(
|
||||||
return buildAugurEntryBlob(recordId);
|
return buildAugurEntryBlob(recordId);
|
||||||
case 'lasts':
|
case 'lasts':
|
||||||
return buildLastBlob(recordId);
|
return buildLastBlob(recordId);
|
||||||
|
case 'forms':
|
||||||
|
return buildFormBlob(recordId);
|
||||||
default:
|
default:
|
||||||
throw new UnsupportedCollectionError(collection);
|
throw new UnsupportedCollectionError(collection);
|
||||||
}
|
}
|
||||||
|
|
@ -268,3 +271,59 @@ async function buildLastBlob(recordId: string): Promise<Record<string, unknown>>
|
||||||
wouldReclaim: decrypted.wouldReclaim ?? null,
|
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/<token>` — 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<Record<string, unknown>> {
|
||||||
|
const raw = await db.table<LocalForm>('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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,10 @@
|
||||||
"allowMultiple": "Mehrere Antworten pro Person erlauben",
|
"allowMultiple": "Mehrere Antworten pro Person erlauben",
|
||||||
"anonymous": "Anonym — Submitter-Daten nicht speichern"
|
"anonymous": "Anonym — Submitter-Daten nicht speichern"
|
||||||
},
|
},
|
||||||
|
"visibility": {
|
||||||
|
"title": "Sichtbarkeit & Teilen",
|
||||||
|
"publishHint": "Setze den Status auf \"Veröffentlicht\", um zu teilen."
|
||||||
|
},
|
||||||
"viewResponses": "Antworten ({n})"
|
"viewResponses": "Antworten ({n})"
|
||||||
},
|
},
|
||||||
"responses": {
|
"responses": {
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,10 @@
|
||||||
"allowMultiple": "Allow multiple submissions per person",
|
"allowMultiple": "Allow multiple submissions per person",
|
||||||
"anonymous": "Anonymous — don't store submitter data"
|
"anonymous": "Anonymous — don't store submitter data"
|
||||||
},
|
},
|
||||||
|
"visibility": {
|
||||||
|
"title": "Visibility & Sharing",
|
||||||
|
"publishHint": "Set the status to \"Published\" to share."
|
||||||
|
},
|
||||||
"viewResponses": "Responses ({n})"
|
"viewResponses": "Responses ({n})"
|
||||||
},
|
},
|
||||||
"responses": {
|
"responses": {
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,10 @@
|
||||||
"allowMultiple": "Permitir varias respuestas por persona",
|
"allowMultiple": "Permitir varias respuestas por persona",
|
||||||
"anonymous": "Anónimo — no guardar datos del remitente"
|
"anonymous": "Anónimo — no guardar datos del remitente"
|
||||||
},
|
},
|
||||||
|
"visibility": {
|
||||||
|
"title": "Visibilidad y compartir",
|
||||||
|
"publishHint": "Pon el estado en \"Publicado\" para compartir."
|
||||||
|
},
|
||||||
"viewResponses": "Respuestas ({n})"
|
"viewResponses": "Respuestas ({n})"
|
||||||
},
|
},
|
||||||
"responses": {
|
"responses": {
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,10 @@
|
||||||
"allowMultiple": "Autoriser plusieurs réponses par personne",
|
"allowMultiple": "Autoriser plusieurs réponses par personne",
|
||||||
"anonymous": "Anonyme — ne pas conserver les données du destinataire"
|
"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})"
|
"viewResponses": "Réponses ({n})"
|
||||||
},
|
},
|
||||||
"responses": {
|
"responses": {
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,10 @@
|
||||||
"allowMultiple": "Permetti più risposte per persona",
|
"allowMultiple": "Permetti più risposte per persona",
|
||||||
"anonymous": "Anonimo — non memorizzare i dati del mittente"
|
"anonymous": "Anonimo — non memorizzare i dati del mittente"
|
||||||
},
|
},
|
||||||
|
"visibility": {
|
||||||
|
"title": "Visibilità e condivisione",
|
||||||
|
"publishHint": "Imposta lo stato su \"Pubblicato\" per condividere."
|
||||||
|
},
|
||||||
"viewResponses": "Risposte ({n})"
|
"viewResponses": "Risposte ({n})"
|
||||||
},
|
},
|
||||||
"responses": {
|
"responses": {
|
||||||
|
|
|
||||||
477
apps/mana/apps/web/src/lib/modules/forms/SharedFormView.svelte
Normal file
477
apps/mana/apps/web/src/lib/modules/forms/SharedFormView.svelte
Normal file
|
|
@ -0,0 +1,477 @@
|
||||||
|
<!--
|
||||||
|
SharedFormView — public-render of an unlisted form. Rendered by the
|
||||||
|
/share/[token] dispatcher when the resolver returned a forms blob.
|
||||||
|
Runs without auth — the schema is the only client-side state.
|
||||||
|
|
||||||
|
M4b ships this as the rendering surface; the actual Public-Submit
|
||||||
|
POST to mana-api `/api/v1/forms/public/<token>/submit` lands in M3.b
|
||||||
|
alongside server-side validation + sync-pickup. Until then, the
|
||||||
|
submit button is disabled with an explanatory note.
|
||||||
|
-->
|
||||||
|
<script lang="ts">
|
||||||
|
import { resolveVisibleFields } from './lib/branching';
|
||||||
|
import type { AnswerValue, BranchingRule, FieldOption, FormField, FormSettings } from './types';
|
||||||
|
|
||||||
|
interface FormBlob {
|
||||||
|
title: string;
|
||||||
|
description: string | null;
|
||||||
|
fields: FormField[];
|
||||||
|
branching: BranchingRule[];
|
||||||
|
settings: Pick<FormSettings, 'submitButtonLabel' | 'successMessage'>;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
blob,
|
||||||
|
token: _token,
|
||||||
|
expiresAt,
|
||||||
|
}: {
|
||||||
|
blob: Record<string, unknown>;
|
||||||
|
token: string;
|
||||||
|
expiresAt: string | null;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
const form = $derived(blob as unknown as FormBlob);
|
||||||
|
|
||||||
|
let answers = $state<Record<string, AnswerValue>>({});
|
||||||
|
let submitted = $state(false);
|
||||||
|
|
||||||
|
const visibleFields = $derived(resolveVisibleFields(form.fields, form.branching ?? [], answers));
|
||||||
|
|
||||||
|
function setAnswer(fieldId: string, value: AnswerValue) {
|
||||||
|
answers = { ...answers, [fieldId]: value };
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleMulti(fieldId: string, optionId: string) {
|
||||||
|
const current = (answers[fieldId] as string[] | undefined) ?? [];
|
||||||
|
const next = current.includes(optionId)
|
||||||
|
? current.filter((v) => v !== optionId)
|
||||||
|
: [...current, optionId];
|
||||||
|
setAnswer(fieldId, next);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ratingScale(field: FormField): number[] {
|
||||||
|
const max = field.config?.ratingScale ?? 5;
|
||||||
|
return Array.from({ length: max }, (_, i) => i + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isFieldRequired(field: FormField): boolean {
|
||||||
|
return field.required && field.type !== 'section';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isFieldFilled(field: FormField): boolean {
|
||||||
|
const v = answers[field.id];
|
||||||
|
if (v === null || v === undefined) return false;
|
||||||
|
if (typeof v === 'string') return v.trim().length > 0;
|
||||||
|
if (Array.isArray(v)) return v.length > 0;
|
||||||
|
if (typeof v === 'boolean') return v === true;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const allRequiredFilled = $derived(visibleFields.filter(isFieldRequired).every(isFieldFilled));
|
||||||
|
|
||||||
|
function handleSubmit(e: SubmitEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
// Public-Submit endpoint not yet wired (M3.b). Once it lands, this
|
||||||
|
// posts answers to /api/v1/forms/public/<token>/submit and shows
|
||||||
|
// the success message.
|
||||||
|
submitted = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtExpiry(iso: string | null): string {
|
||||||
|
if (!iso) return '';
|
||||||
|
try {
|
||||||
|
return new Date(iso).toLocaleDateString();
|
||||||
|
} catch {
|
||||||
|
return iso;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<article class="public-form">
|
||||||
|
<header class="hero">
|
||||||
|
<h1>{form.title}</h1>
|
||||||
|
{#if form.description}
|
||||||
|
<p class="description">{form.description}</p>
|
||||||
|
{/if}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{#if submitted}
|
||||||
|
<div class="thanks">
|
||||||
|
<p class="thanks-message">{form.settings.successMessage}</p>
|
||||||
|
<p class="dev-note">
|
||||||
|
<em
|
||||||
|
>Hinweis: Public-Submit ist im aktuellen Mana-Build noch nicht serverseitig verdrahtet —
|
||||||
|
deine Antwort wurde nicht übermittelt.</em
|
||||||
|
>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<form class="form-body" onsubmit={handleSubmit}>
|
||||||
|
{#each visibleFields as field (field.id)}
|
||||||
|
<div class="field" data-type={field.type}>
|
||||||
|
{#if field.type === 'section'}
|
||||||
|
<hr class="section-divider" />
|
||||||
|
<h2 class="section-title">{field.label}</h2>
|
||||||
|
{:else if field.type === 'consent'}
|
||||||
|
<label class="consent-row">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={answers[field.id] === true}
|
||||||
|
onchange={(e) => setAnswer(field.id, (e.currentTarget as HTMLInputElement).checked)}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
>{field.label}
|
||||||
|
{#if field.required}<em class="req">*</em>{/if}</span
|
||||||
|
>
|
||||||
|
</label>
|
||||||
|
{:else}
|
||||||
|
<label class="field-label">
|
||||||
|
{field.label}
|
||||||
|
{#if field.required}<em class="req">*</em>{/if}
|
||||||
|
</label>
|
||||||
|
{#if field.helpText}
|
||||||
|
<p class="help">{field.helpText}</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if field.type === 'short_text'}
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
maxlength={field.config?.maxLength ?? 200}
|
||||||
|
value={(answers[field.id] as string | undefined) ?? ''}
|
||||||
|
oninput={(e) => setAnswer(field.id, (e.currentTarget as HTMLInputElement).value)}
|
||||||
|
/>
|
||||||
|
{:else if field.type === 'long_text'}
|
||||||
|
<textarea
|
||||||
|
rows="4"
|
||||||
|
maxlength={field.config?.maxLength ?? 4000}
|
||||||
|
value={(answers[field.id] as string | undefined) ?? ''}
|
||||||
|
oninput={(e) => setAnswer(field.id, (e.currentTarget as HTMLTextAreaElement).value)}
|
||||||
|
></textarea>
|
||||||
|
{:else if field.type === 'email'}
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={(answers[field.id] as string | undefined) ?? ''}
|
||||||
|
oninput={(e) => setAnswer(field.id, (e.currentTarget as HTMLInputElement).value)}
|
||||||
|
/>
|
||||||
|
{:else if field.type === 'number'}
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={field.config?.min}
|
||||||
|
max={field.config?.max}
|
||||||
|
value={(answers[field.id] as number | string | undefined) ?? ''}
|
||||||
|
oninput={(e) => {
|
||||||
|
const v = (e.currentTarget as HTMLInputElement).value;
|
||||||
|
setAnswer(field.id, v === '' ? null : Number(v));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{:else if field.type === 'date'}
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={(answers[field.id] as string | undefined) ?? ''}
|
||||||
|
oninput={(e) => setAnswer(field.id, (e.currentTarget as HTMLInputElement).value)}
|
||||||
|
/>
|
||||||
|
{:else if field.type === 'yes_no'}
|
||||||
|
<div class="yes-no">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="yn-btn"
|
||||||
|
class:active={answers[field.id] === true}
|
||||||
|
onclick={() => setAnswer(field.id, true)}>Ja</button
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="yn-btn"
|
||||||
|
class:active={answers[field.id] === false}
|
||||||
|
onclick={() => setAnswer(field.id, false)}>Nein</button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
{:else if field.type === 'rating'}
|
||||||
|
<div class="rating">
|
||||||
|
{#each ratingScale(field) as n}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rate-btn"
|
||||||
|
class:active={(answers[field.id] as number | undefined) === n}
|
||||||
|
onclick={() => setAnswer(field.id, n)}>{n}</button
|
||||||
|
>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else if field.type === 'single_choice'}
|
||||||
|
<div class="options">
|
||||||
|
{#each field.options ?? [] as opt (opt.id)}
|
||||||
|
<label class="option-row">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name={field.id}
|
||||||
|
value={opt.id}
|
||||||
|
checked={answers[field.id] === opt.id}
|
||||||
|
onchange={() => setAnswer(field.id, opt.id)}
|
||||||
|
/>
|
||||||
|
<span>{opt.label}</span>
|
||||||
|
</label>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else if field.type === 'multi_choice'}
|
||||||
|
<div class="options">
|
||||||
|
{#each field.options ?? [] as opt (opt.id)}
|
||||||
|
{@const checked = ((answers[field.id] as string[] | undefined) ?? []).includes(
|
||||||
|
opt.id
|
||||||
|
)}
|
||||||
|
<label class="option-row">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
value={opt.id}
|
||||||
|
{checked}
|
||||||
|
onchange={() => toggleMulti(field.id, opt.id)}
|
||||||
|
/>
|
||||||
|
<span>{opt.label}</span>
|
||||||
|
</label>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
<footer class="form-footer">
|
||||||
|
<button type="submit" class="submit" disabled={!allRequiredFilled}>
|
||||||
|
{form.settings.submitButtonLabel}
|
||||||
|
</button>
|
||||||
|
<p class="dev-note">
|
||||||
|
<em
|
||||||
|
>Public-Submit landet im nächsten Mana-Schritt — vorerst zeigt der Klick nur die
|
||||||
|
Bestätigungsnachricht.</em
|
||||||
|
>
|
||||||
|
</p>
|
||||||
|
{#if expiresAt}
|
||||||
|
<p class="expiry">Dieser Link läuft am {fmtExpiry(expiresAt)} ab.</p>
|
||||||
|
{/if}
|
||||||
|
</footer>
|
||||||
|
</form>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<footer class="brand">
|
||||||
|
<a href="https://mana.how/forms" class="brand-link">via Mana Forms</a>
|
||||||
|
</footer>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.public-form {
|
||||||
|
max-width: 640px;
|
||||||
|
margin: 2rem auto;
|
||||||
|
padding: 1.5rem;
|
||||||
|
background: white;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.06);
|
||||||
|
color: #1a1a1a;
|
||||||
|
font-family:
|
||||||
|
system-ui,
|
||||||
|
-apple-system,
|
||||||
|
sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero {
|
||||||
|
border-bottom: 1px solid #e5e7eb;
|
||||||
|
padding-bottom: 0.875rem;
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero h1 {
|
||||||
|
margin: 0 0 0.375rem;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.description {
|
||||||
|
margin: 0;
|
||||||
|
color: #4b5563;
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-label,
|
||||||
|
.consent-row {
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.req {
|
||||||
|
color: #dc2626;
|
||||||
|
font-style: normal;
|
||||||
|
margin-left: 0.125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field input[type='text'],
|
||||||
|
.field input[type='email'],
|
||||||
|
.field input[type='number'],
|
||||||
|
.field input[type='date'],
|
||||||
|
.field textarea {
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
background: white;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
color: inherit;
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field input:focus,
|
||||||
|
.field textarea:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #14b8a6;
|
||||||
|
box-shadow: 0 0 0 3px rgba(20, 184, 166, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-divider {
|
||||||
|
border: none;
|
||||||
|
border-top: 1px solid #e5e7eb;
|
||||||
|
margin: 0.5rem 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
margin: 0.25rem 0 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #374151;
|
||||||
|
}
|
||||||
|
|
||||||
|
.consent-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-weight: normal;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #374151;
|
||||||
|
}
|
||||||
|
|
||||||
|
.options {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.option-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
font-weight: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.yes-no,
|
||||||
|
.rating {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.375rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.yn-btn,
|
||||||
|
.rate-btn {
|
||||||
|
min-width: 2.5rem;
|
||||||
|
padding: 0.375rem 0.75rem;
|
||||||
|
background: white;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
color: inherit;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.yn-btn:hover,
|
||||||
|
.rate-btn:hover {
|
||||||
|
background: #f9fafb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.yn-btn.active,
|
||||||
|
.rate-btn.active {
|
||||||
|
background: #14b8a6;
|
||||||
|
border-color: #14b8a6;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-footer {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit {
|
||||||
|
padding: 0.625rem 1.25rem;
|
||||||
|
background: #14b8a6;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
color: white;
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit:hover:not(:disabled) {
|
||||||
|
background: #0f766e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit:disabled {
|
||||||
|
background: #d1d5db;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dev-note {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expiry {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thanks {
|
||||||
|
text-align: center;
|
||||||
|
padding: 2rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thanks-message {
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
color: #1a1a1a;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
padding-top: 0.75rem;
|
||||||
|
border-top: 1px solid #e5e7eb;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-link {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #6b7280;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-link:hover {
|
||||||
|
color: #14b8a6;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -3,6 +3,16 @@ import { toForm } from '../queries';
|
||||||
import { encryptRecord } from '$lib/data/crypto';
|
import { encryptRecord } from '$lib/data/crypto';
|
||||||
import { DEFAULT_FORM_SETTINGS } from '../types';
|
import { DEFAULT_FORM_SETTINGS } from '../types';
|
||||||
import type { BranchingRule, Form, FormField, FormSettings, FormStatus, LocalForm } 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 {
|
function nowIso(): string {
|
||||||
return new Date().toISOString();
|
return new Date().toISOString();
|
||||||
|
|
@ -96,4 +106,134 @@ export const formsStore = {
|
||||||
await encryptRecord('forms', diff);
|
await encryptRecord('forms', diff);
|
||||||
await formTable.update(id, 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<LocalForm> = {
|
||||||
|
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<string | null> {
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,12 @@
|
||||||
import FieldPalette from '../components/FieldPalette.svelte';
|
import FieldPalette from '../components/FieldPalette.svelte';
|
||||||
import SettingsPanel from '../components/SettingsPanel.svelte';
|
import SettingsPanel from '../components/SettingsPanel.svelte';
|
||||||
import BranchingEditor from '../components/BranchingEditor.svelte';
|
import BranchingEditor from '../components/BranchingEditor.svelte';
|
||||||
|
import {
|
||||||
|
VisibilityPicker,
|
||||||
|
SharedLinkControls,
|
||||||
|
buildShareUrl,
|
||||||
|
type VisibilityLevel,
|
||||||
|
} from '@mana/shared-privacy';
|
||||||
|
|
||||||
let { entry }: { entry: Form } = $props();
|
let { entry }: { entry: Form } = $props();
|
||||||
|
|
||||||
|
|
@ -113,6 +119,36 @@
|
||||||
await formsStore.updateBranching(entry.id, next);
|
await formsStore.updateBranching(entry.id, next);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Visibility / Share-Link ────────────────────────────
|
||||||
|
let visibilityError = $state<string | null>(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) {
|
async function setStatus(status: FormStatus) {
|
||||||
await formsStore.setStatus(entry.id, status);
|
await formsStore.setStatus(entry.id, status);
|
||||||
}
|
}
|
||||||
|
|
@ -226,6 +262,35 @@
|
||||||
<FieldPalette onpick={pickField} />
|
<FieldPalette onpick={pickField} />
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="visibility-section">
|
||||||
|
<header class="vis-header">
|
||||||
|
<p class="panel-title">
|
||||||
|
{$_('forms.builder.visibility.title', { default: 'Sichtbarkeit & Teilen' })}
|
||||||
|
</p>
|
||||||
|
{#if entry.status !== 'published' && entry.visibility !== 'unlisted'}
|
||||||
|
<span class="vis-hint">
|
||||||
|
{$_('forms.builder.visibility.publishHint', {
|
||||||
|
default: 'Setze den Status auf "Veröffentlicht", um zu teilen.',
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</header>
|
||||||
|
<VisibilityPicker level={entry.visibility} onChange={onVisibilityChange} />
|
||||||
|
{#if visibilityError}
|
||||||
|
<p class="vis-error">{visibilityError}</p>
|
||||||
|
{/if}
|
||||||
|
{#if entry.visibility === 'unlisted' && entry.unlistedToken && shareUrl}
|
||||||
|
<SharedLinkControls
|
||||||
|
token={entry.unlistedToken}
|
||||||
|
url={shareUrl}
|
||||||
|
expiresAt={entry.unlistedExpiresAt}
|
||||||
|
onRegenerate={handleRegenerate}
|
||||||
|
onRevoke={handleRevoke}
|
||||||
|
onExpiryChange={handleExpiryChange}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="branching-section">
|
<section class="branching-section">
|
||||||
<BranchingEditor fields={items} branching={entry.branching} onchange={patchBranching} />
|
<BranchingEditor fields={items} branching={entry.branching} onchange={patchBranching} />
|
||||||
</section>
|
</section>
|
||||||
|
|
@ -372,12 +437,47 @@
|
||||||
|
|
||||||
.fields-section,
|
.fields-section,
|
||||||
.settings-section,
|
.settings-section,
|
||||||
.branching-section {
|
.branching-section,
|
||||||
|
.visibility-section {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.625rem;
|
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 {
|
.section-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: baseline;
|
align-items: baseline;
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@
|
||||||
import SharedPlaceView from '$lib/modules/places/SharedPlaceView.svelte';
|
import SharedPlaceView from '$lib/modules/places/SharedPlaceView.svelte';
|
||||||
import SharedAugurEntryView from '$lib/modules/augur/SharedAugurEntryView.svelte';
|
import SharedAugurEntryView from '$lib/modules/augur/SharedAugurEntryView.svelte';
|
||||||
import SharedLastView from '$lib/modules/lasts/SharedLastView.svelte';
|
import SharedLastView from '$lib/modules/lasts/SharedLastView.svelte';
|
||||||
|
import SharedFormView from '$lib/modules/forms/SharedFormView.svelte';
|
||||||
import type { PageData } from './$types';
|
import type { PageData } from './$types';
|
||||||
|
|
||||||
let { data }: { data: PageData } = $props();
|
let { data }: { data: PageData } = $props();
|
||||||
|
|
@ -24,6 +25,8 @@
|
||||||
<SharedAugurEntryView blob={data.blob} token={data.token} expiresAt={data.expiresAt} />
|
<SharedAugurEntryView blob={data.blob} token={data.token} expiresAt={data.expiresAt} />
|
||||||
{:else if data.collection === 'lasts'}
|
{:else if data.collection === 'lasts'}
|
||||||
<SharedLastView blob={data.blob} token={data.token} expiresAt={data.expiresAt} />
|
<SharedLastView blob={data.blob} token={data.token} expiresAt={data.expiresAt} />
|
||||||
|
{:else if data.collection === 'forms'}
|
||||||
|
<SharedFormView blob={data.blob} token={data.token} expiresAt={data.expiresAt} />
|
||||||
{:else}
|
{:else}
|
||||||
<div class="unknown">
|
<div class="unknown">
|
||||||
<h1>Unbekannter Link-Typ</h1>
|
<h1>Unbekannter Link-Typ</h1>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue