Erste Produktivversion

This commit is contained in:
2026-08-08 20:31:58 +02:00
parent d8ded2816d
commit 7b21e2d1b4
110 changed files with 18170 additions and 644 deletions

468
web/html/js/views/share.js Normal file
View File

@@ -0,0 +1,468 @@
// 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"))
)
);
}