Erste Produktivversion
This commit is contained in:
468
web/html/js/views/share.js
Normal file
468
web/html/js/views/share.js
Normal 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"))
|
||||
)
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user