Files
einkaufsapp/web/html/js/views/share.js
2026-08-09 18:44:21 +02:00

471 lines
15 KiB
JavaScript
Raw Permalink 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.
// 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";
import { appBar } from "../appbar.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,
appBar({
back: { label: "Zurück", onClick: back },
title: list ? list.name : "Teilen",
}),
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"))
)
);
}