// Teilen einer Liste: Einladen, erneut senden, widerrufen, Zugriff // entziehen, Eigentum übertragen. Nur für den Eigentümer erreichbar. "use strict"; import { del, get, post, put } from "../api.js"; import { clear, el, mount } from "../dom.js"; const ROLE_LABEL = { owner: "Eigentümer", editor: "Bearbeiter", viewer: "Nur lesen" }; const STATUS_LABEL = { pending: "offen", accepted: "angenommen", revoked: "widerrufen", expired: "abgelaufen", }; function formatDate(iso) { return new Date(iso).toLocaleDateString("de-DE", { day: "2-digit", month: "2-digit", year: "numeric", }); } export async function shareView(root, { listId, back }) { let list = null; let members = []; let invites = []; let banner = null; let publicLinks = []; // Der Klartext eines neu erzeugten Links - nur bis zum nächsten Neuzeichnen // im Speicher, danach nicht mehr rekonstruierbar. let freshLink = null; async function reload() { [list, members, invites, publicLinks] = await Promise.all([ get(`/api/lists/${listId}`), get(`/api/lists/${listId}/members`), get(`/api/lists/${listId}/invites`), get(`/api/lists/${listId}/public-links`), ]); } function say(message, kind = "notice") { banner = { message, kind }; render(); setTimeout(() => { banner = null; render(); }, 8000); } async function guarded(fn) { try { const result = await fn(); await reload(); render(); if (result && result.detail) say(result.detail); } catch (err) { say(err.message, "error"); } } // ---------------- Einladen ---------------- function inviteForm() { const email = el("input", { type: "email", autocomplete: "off", placeholder: "person@example.de", onkeydown: (ev) => { if (ev.key === "Enter") submit(); }, }); const role = el("select", {}, el("option", { value: "editor" }, "Bearbeiter – darf ändern"), el("option", { value: "viewer" }, "Nur lesen") ); const button = el("button.primary", { type: "button", onclick: () => submit() }, "Einladen"); async function submit() { const value = email.value.trim(); if (!value) return; button.disabled = true; try { await post(`/api/lists/${listId}/invites`, { email: value, role: role.value }); email.value = ""; await reload(); render(); say(`Einladung an ${value} versendet.`); } catch (err) { say(err.message, "error"); } finally { button.disabled = false; } } return el("section.card", {}, el("h2", {}, "Person einladen"), el("p.lead", {}, "Die Einladung geht per E-Mail an die angegebene Adresse und gilt nur " + "für diese. Ein Konto braucht die Person noch nicht – sie kann sich " + "beim Öffnen des Links eines anlegen."), el("label", {}, "E-Mail-Adresse"), email, el("label", {}, "Berechtigung"), role, button ); } // ---------------- Mitglieder ---------------- function memberRow(member) { const isOwner = member.role === "owner"; const name = member.display_name || member.email; const roleSelect = isOwner ? null : el("select.role", { onchange: (ev) => guarded(() => put(`/api/lists/${listId}/members/${member.user_id}`, { role: ev.target.value, may_share_public: member.may_share_public, })), }, el("option", { value: "editor", selected: member.role === "editor" }, "Bearbeiter"), el("option", { value: "viewer", selected: member.role === "viewer" }, "Nur lesen") ); // Zusatzrecht, unabhängig von der Rolle. const shareRight = isOwner ? null : el("label.checkline", {}, el("input", { type: "checkbox", checked: member.may_share_public, onchange: (ev) => guarded(() => put(`/api/lists/${listId}/members/${member.user_id}`, { role: member.role, may_share_public: ev.target.checked, })), }), el("span", {}, "darf öffentliche Links erzeugen") ); return el("li", {}, el("div.who-block", {}, el("span.name", {}, name), el("span.sub", {}, member.display_name ? `${member.email} · ` : "", isOwner ? ROLE_LABEL.owner : `dabei seit ${formatDate(member.joined_at)}`) ), roleSelect, shareRight, isOwner ? null : el("div.member-actions", {}, el("button", { type: "button", title: "Eigentum übertragen", onclick: () => { if (!confirm( `Eigentum an „${list.name}“ auf ${name} übertragen?\n\n` + "Du wirst dabei zum Bearbeiter und kannst die Freigabe " + "danach nicht mehr verwalten." )) return; guarded(() => post(`/api/lists/${listId}/transfer`, { user_id: member.user_id })); }, }, "Eigentum übertragen"), el("button.danger", { type: "button", onclick: () => { if (!confirm(`${name} den Zugriff auf „${list.name}“ entziehen?`)) return; guarded(() => del(`/api/lists/${listId}/members/${member.user_id}`)); }, }, "Zugriff entziehen") ) ); } // ---------------- Einladungen ---------------- function inviteRow(invite) { const open = invite.status === "pending"; const done = invite.status === "accepted"; return el("li", { className: `invite ${invite.status}` }, el("div.who-block", {}, el("span.name", {}, invite.email), el("span.sub", {}, `${ROLE_LABEL[invite.role]} · ${STATUS_LABEL[invite.status]}`, open ? ` bis ${formatDate(invite.expires_at)}` : "", invite.send_count > 1 ? ` · ${invite.send_count}× gesendet` : "") ), done ? null : el("div.member-actions", {}, el("button", { type: "button", onclick: () => guarded(() => post(`/api/lists/${listId}/invites/${invite.id}/resend`)), }, invite.status === "revoked" || invite.status === "expired" ? "Neu senden" : "Erneut senden"), open ? el("button.danger", { type: "button", onclick: () => { if (!confirm( `Einladung an ${invite.email} widerrufen?\n\n` + "Der bereits versendete Link funktioniert danach nicht mehr." )) return; guarded(() => del(`/api/lists/${listId}/invites/${invite.id}`)); }, }, "Widerrufen") : null ) ); } // ---------------- Öffentliche Links ---------------- function defaultExpiry(days) { const d = new Date(); d.setDate(d.getDate() + days); d.setHours(23, 59, 0, 0); return d; } function toDateInput(date) { const pad = (n) => String(n).padStart(2, "0"); return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`; } function publicSection() { const label = el("input", { type: "text", maxLength: 120, placeholder: "z. B. „für Oma“ – nur zur Unterscheidung", }); const until = el("input", { type: "date", value: toDateInput(defaultExpiry(14)), min: toDateInput(defaultExpiry(1)), max: toDateInput(defaultExpiry(365)), }); const allowCheck = el("input", { type: "checkbox", checked: true }); const button = el("button.primary", { type: "button", onclick: () => create() }, "Link erzeugen"); async function create() { if (!until.value) { say("Bitte ein Ablaufdatum angeben.", "error"); return; } const expires = new Date(until.value); expires.setHours(23, 59, 0, 0); button.disabled = true; try { const result = await post(`/api/lists/${listId}/public-links`, { label: label.value.trim() || null, expires_at: expires.toISOString(), allow_check: allowCheck.checked, }); freshLink = result.url; await reload(); render(); } catch (err) { say(err.message, "error"); } finally { button.disabled = false; } } const active = publicLinks.filter((l) => l.status === "active"); return el("section.card", {}, el("h2", {}, "Öffentlicher Link"), el("p.lead", {}, "Wer den Link hat, kann die Liste ansehen – ohne Konto. Namen der " + "Personen, die Artikel eingetragen haben, werden dabei nicht " + "angezeigt. Behandle den Link wie einen Schlüssel: Weitergabe " + "bedeutet Zugriff."), freshLink ? el("div.fresh-link", {}, el("p", {}, el("strong", {}, "Der Link – jetzt kopieren:")), el("input.linkfield", { type: "text", value: freshLink, readOnly: true, onclick: (ev) => ev.target.select(), }), el("div.member-actions", {}, el("button", { type: "button", onclick: async (ev) => { try { await navigator.clipboard.writeText(freshLink); ev.target.textContent = "Kopiert"; } catch { say("Kopieren nicht möglich – bitte von Hand markieren.", "error"); } }, }, "In die Zwischenablage"), el("button", { type: "button", onclick: () => { freshLink = null; render(); } }, "Ausblenden")), el("p.sub", {}, "Aus Sicherheitsgründen steht in der Datenbank nur ein Prüfwert. " + "Später lässt sich die Adresse nicht mehr anzeigen – dann bleibt " + "nur, einen neuen Link zu erzeugen.") ) : null, publicLinks.length ? el("ul.share-list", {}, publicLinks.map(publicLinkRow)) : el("p.empty", {}, "Noch kein Link erzeugt."), el("label", {}, "Bezeichnung ", el("span.hint", {}, "(freiwillig)")), label, el("label", {}, "Gültig bis"), until, el("label.checkline", {}, allowCheck, el("span", {}, "Abhaken erlauben (sonst nur ansehen)")), button, active.length > 1 ? el("button.danger.wide", { type: "button", onclick: () => { if (!confirm(`Alle ${active.length} aktiven Links widerrufen?`)) return; guarded(() => post(`/api/lists/${listId}/public-links/revoke-all`)); }, }, "Alle Links widerrufen") : null ); } function publicLinkRow(link) { const STATE = { active: "aktiv", expired: "abgelaufen", revoked: "widerrufen" }; return el("li", { className: `invite ${link.status === "active" ? "" : "revoked"}` }, el("div.who-block", {}, el("span.name", {}, link.label || "Ohne Bezeichnung"), el("span.sub", {}, `${STATE[link.status]} · gültig bis ${formatDate(link.expires_at)}`, link.allow_check ? " · Abhaken erlaubt" : " · nur ansehen", link.access_count ? ` · ${link.access_count}× geöffnet, zuletzt ${formatDate(link.last_access_at)}` : " · noch nicht geöffnet") ), link.status === "revoked" ? null : el("div.member-actions", {}, el("button.danger", { type: "button", onclick: () => { if (!confirm( "Diesen Link widerrufen?\n\n" + "Er funktioniert danach für alle nicht mehr, die ihn haben." )) return; guarded(() => del(`/api/lists/${listId}/public-links/${link.id}`)); }, }, "Widerrufen")) ); } // ---------------- Gesamtansicht ---------------- function render() { const shared = members.filter((m) => m.role !== "owner"); const openInvites = invites.filter((i) => i.status === "pending"); mount(root, el("header.bar", {}, el("button.linklike", { type: "button", onclick: back }, "‹ Zurück zur Liste") ), banner ? el(banner.kind === "error" ? "p.error" : "p.notice", {}, banner.message) : null, el("section.card", {}, el("h1", {}, "Teilen"), el("p.lead", {}, `„${list.name}“ ist derzeit `, shared.length === 0 && openInvites.length === 0 ? "mit niemandem geteilt." : `mit ${shared.length} Person(en) geteilt` + (openInvites.length ? `, ${openInvites.length} Einladung(en) offen.` : ".")) ), inviteForm(), list.may_share_public ? publicSection() : null, el("section.card", {}, el("h2", {}, "Zugriff"), el("ul.share-list", {}, members.map(memberRow)) ), invites.length ? el("section.card", {}, el("h2", {}, "Einladungen"), el("ul.share-list", {}, invites.map(inviteRow))) : null, shared.length || openInvites.length ? el("section.card", {}, el("h2", {}, "Freigabe aufheben"), el("p.lead", {}, "Entzieht allen Personen den Zugriff und widerruft offene " + "Einladungen. Die Liste selbst bleibt bestehen."), el("button.danger.wide", { type: "button", onclick: () => { if (!confirm( `Freigabe von „${list.name}“ vollständig aufheben?\n\n` + `${shared.length} Zugriff(e) werden entzogen, ` + `${openInvites.length} Einladung(en) widerrufen.` )) return; guarded(() => post(`/api/lists/${listId}/unshare`)); }, }, "Freigabe vollständig aufheben")) : null ); } await reload(); render(); return render; } // --------------------------------------------------------------------------- // Empfängerseite: Einladung annehmen // --------------------------------------------------------------------------- export async function acceptInviteView(root, { token, onAccepted, toLists }) { let preview; try { preview = await get(`/api/invites/${encodeURIComponent(token)}`); } catch (err) { mount(root, el("section.card", {}, el("h1", {}, "Einladung"), el("p.error", {}, err.message), el("button.primary", { type: "button", onclick: toLists }, "Zu meinen Listen") ) ); return; } const button = el("button.primary", { type: "button" }, "Einladung annehmen"); const error = el("p.error", { hidden: true }); button.addEventListener("click", async () => { button.disabled = true; error.hidden = true; try { await post(`/api/invites/${encodeURIComponent(token)}/accept`); onAccepted(); } catch (err) { error.textContent = err.message; error.hidden = false; button.disabled = false; } }); mount(root, el("section.card", {}, el("h1", {}, "Einladung"), el("p.lead", {}, preview.invited_by_name ? `${preview.invited_by_name} teilt die Liste „${preview.list_name}“ mit dir.` : `Die Liste „${preview.list_name}“ wurde mit dir geteilt.`), el("p", {}, `Berechtigung: ${ROLE_LABEL[preview.role]}`), preview.matches_current_user ? null : el("p.lead.warn", {}, `Diese Einladung ist an ${preview.email} gerichtet, du bist aber mit ` + "einem anderen Konto angemeldet. Melde dich mit der eingeladenen " + "Adresse an oder bitte um eine neue Einladung."), error, preview.matches_current_user ? button : null, el("p.switch", {}, el("button.linklike", { type: "button", onclick: toLists }, "Später")) ) ); }