big update in share links
This commit is contained in:
+54
-9
@@ -121,6 +121,8 @@ function authHeaders() { return {}; }
|
||||
function escapeHtml(value) { const node = document.createElement("div"); node.textContent = String(value ?? ""); return node.innerHTML; }
|
||||
function shareExpiry(hours, forever) { if (forever) return null; const value = Number(hours); if (!Number.isFinite(value) || value <= 0 || value > 87600) throw new Error("Enter a validity between 1 and 87600 hours."); return new Date(Date.now() + value * 3600000).toISOString(); }
|
||||
function formatShareExpiry(value) { if (!value) return "Never expires"; const date = new Date(value); return Number.isNaN(date.getTime()) ? value : `Expires ${date.toLocaleString()}`; }
|
||||
function formatShareCreated(value) { const date = new Date(value); return Number.isNaN(date.getTime()) ? value : `Created ${date.toLocaleString()}`; }
|
||||
function shareLinkId(tokenHash) { return String(tokenHash || "").slice(0, 12); }
|
||||
function renderResourcesPagination(meta) {
|
||||
resourcesPagination.innerHTML = meta.total ? `<button type="button" data-page="${meta.page - 1}" ${meta.page <= 1 ? "disabled" : ""}>Previous</button><span>Page ${meta.page} of ${meta.total_pages} · ${meta.total} items</span><button type="button" data-page="${meta.page + 1}" ${meta.page >= meta.total_pages ? "disabled" : ""}>Next</button>` : "";
|
||||
}
|
||||
@@ -166,6 +168,7 @@ async function loadResources() {
|
||||
}
|
||||
});
|
||||
row.querySelector("[data-share]")?.addEventListener("click", async () => {
|
||||
const initialShareLinksEnabled = Boolean(item.private || item.protected);
|
||||
const dialog = document.createElement("dialog");
|
||||
dialog.className = "app-dialog share-dialog";
|
||||
dialog.innerHTML = `<div class="share-panel">
|
||||
@@ -176,12 +179,13 @@ async function loadResources() {
|
||||
<label><span>Permission</span><select name="permission"><option value="ro">Read only</option><option value="rw">Read and write</option></select></label>
|
||||
<button class="primary-button" type="submit">Grant access</button>
|
||||
</form><div data-user-list class="share-list"></div></section>
|
||||
<section class="share-section"><div class="share-section-head"><div><h4>Direct links</h4><p>Create links for people without an account or manage existing links.</p></div></div><form data-link-form class="share-form-grid share-link-form">
|
||||
<section class="share-section" data-direct-links-section><div class="share-section-head"><div><h4>Direct links</h4><p>Create links for people without an account or manage existing links.</p></div></div><div data-direct-links-enabled ${initialShareLinksEnabled ? "" : "hidden"}><form data-link-form class="share-form-grid share-link-form">
|
||||
<label class="share-link-label"><span>Label</span><input name="label" type="text" maxlength="120" placeholder="Client ABC" autocomplete="off"></label>
|
||||
<label><span>Permission</span><select name="permission"><option value="ro">Read only</option><option value="rw">Read and write</option></select></label>
|
||||
<label><span>Valid for</span><div class="share-hours-field"><input name="hours" type="number" min="1" max="87600" value="24" inputmode="numeric"><span>hours</span></div></label>
|
||||
<label class="share-forever"><input name="forever" type="checkbox"><span>Never expires</span></label>
|
||||
<button class="primary-button" type="submit">Create link</button>
|
||||
</form><div class="share-link-list-wrap"><h5>Individual links</h5><div data-link-list class="share-list share-link-list"></div></div></section>
|
||||
</form><div class="share-link-list-wrap"><h5>Individual links</h5><div data-link-list class="share-list share-link-list"></div></div></div><div class="share-empty" data-direct-links-disabled ${initialShareLinksEnabled ? "hidden" : ""}>Direct links are disabled while this item is public and has no password. Existing links are preserved and become active again after you make it private or add a password.</div></section>
|
||||
</div>
|
||||
<div class="share-dialog-footer"><p class="form-message resource-inline-message" data-inline-message role="status"></p><button class="secondary-button" type="button" data-done>Done</button></div>
|
||||
</div>`;
|
||||
@@ -200,24 +204,65 @@ async function loadResources() {
|
||||
const linkForm = dialog.querySelector("[data-link-form]");
|
||||
const userList = dialog.querySelector("[data-user-list]");
|
||||
const linkList = dialog.querySelector("[data-link-list]");
|
||||
const directLinksEnabled = dialog.querySelector("[data-direct-links-enabled]");
|
||||
const directLinksDisabled = dialog.querySelector("[data-direct-links-disabled]");
|
||||
const createdLinkValues = new Map();
|
||||
const syncForever = () => { linkForm.hours.disabled = linkForm.forever.checked; };
|
||||
linkForm.forever.addEventListener("change", syncForever); syncForever();
|
||||
const refresh = async () => {
|
||||
const d = await api(`/api/auth/resources/sharing?kind=${encodeURIComponent(item.kind)}&slug=${encodeURIComponent(item.slug)}`, { headers: authHeaders() });
|
||||
const shareLinksEnabled = d.share_links_enabled !== false;
|
||||
directLinksEnabled.hidden = !shareLinksEnabled;
|
||||
directLinksDisabled.hidden = shareLinksEnabled;
|
||||
userList.innerHTML = d.users.length ? d.users.map(u => `<div class="share-list-row"><div class="share-list-identity"><span class="share-avatar">${escapeHtml((u.nickname || u.email || "?").slice(0, 1).toUpperCase())}</span><div><strong>${escapeHtml(u.nickname)}</strong><small>${escapeHtml(u.email)}</small></div></div><span class="share-role">${u.permission === "rw" ? "Read and write" : "Read only"}</span><button class="secondary-button compact-button" type="button" data-remove-user="${escapeHtml(u.email)}">Remove</button></div>`).join("") : '<p class="share-empty">No users have access.</p>';
|
||||
linkList.innerHTML = d.links.length ? d.links.map(link => {
|
||||
const linkPreview = '<small class="share-link-legacy">For security, link values are shown only when created. Create a new link to copy it again.</small>';
|
||||
return `<form class="share-list-row share-link-row" data-link-token="${escapeHtml(link.token_hash)}"><div class="share-link-info"><strong>Individual link</strong><small>${escapeHtml(formatShareExpiry(link.expires_at))}</small>${linkPreview}</div><label><span class="sr-only">Permission</span><select name="permission" aria-label="Link permission"><option value="ro" ${link.permission === "ro" ? "selected" : ""}>Read only</option><option value="rw" ${link.permission === "rw" ? "selected" : ""}>Read and write</option></select></label><label><span class="sr-only">Validity in hours</span><div class="share-hours-field"><input name="hours" type="number" min="1" max="87600" value="24" aria-label="New validity in hours"><span>h</span></div></label><label class="share-forever"><input name="forever" type="checkbox" ${link.expires_at ? "" : "checked"}><span>Never</span></label><div class="share-row-actions"><button class="secondary-button compact-button" type="submit">Update</button><button class="danger-button compact-button" type="button" data-revoke-link>Revoke</button></div></form>`;
|
||||
linkList.innerHTML = shareLinksEnabled && d.links.length ? d.links.map(link => {
|
||||
const identifier = shareLinkId(link.token_hash);
|
||||
const visibleLink = createdLinkValues.get(link.token_hash);
|
||||
const linkPreview = visibleLink
|
||||
? `<div class="share-link-inline"><input type="text" readonly value="${escapeHtml(visibleLink)}" aria-label="New share link"><button class="secondary-button compact-button" type="button" data-copy-link>Copy</button></div><small class="share-link-once">The full address remains visible until this dialog is closed.</small>`
|
||||
: `<small class="share-link-legacy">The full address is not stored. Use the label or link ID #${escapeHtml(identifier)} to identify it.</small>`;
|
||||
return `<form class="share-list-row share-link-row" data-link-token-hash="${escapeHtml(link.token_hash)}"><div class="share-link-info"><label class="share-link-label-edit"><span>Label</span><input name="label" type="text" maxlength="120" value="${escapeHtml(link.label || "")}" placeholder="Unlabeled link" autocomplete="off"></label><small>Link ID #${escapeHtml(identifier)} · ${escapeHtml(formatShareCreated(link.created_at))} · ${escapeHtml(formatShareExpiry(link.expires_at))}</small>${linkPreview}</div><label><span class="sr-only">Permission</span><select name="permission" aria-label="Link permission"><option value="ro" ${link.permission === "ro" ? "selected" : ""}>Read only</option><option value="rw" ${link.permission === "rw" ? "selected" : ""}>Read and write</option></select></label><label><span class="sr-only">Validity in hours</span><div class="share-hours-field"><input name="hours" type="number" min="1" max="87600" value="24" aria-label="New validity in hours"><span>h</span></div></label><label class="share-forever"><input name="forever" type="checkbox" ${link.expires_at ? "" : "checked"}><span>Never</span></label><div class="share-row-actions"><button class="secondary-button compact-button" type="submit">Update</button><button class="danger-button compact-button" type="button" data-revoke-link>Revoke</button></div></form>`;
|
||||
}).join("") : '<p class="share-empty">No active links.</p>';
|
||||
userList.querySelectorAll("[data-remove-user]").forEach(button => button.addEventListener("click", async () => { try { button.disabled = true; await api("/api/auth/resources/sharing", { method: "DELETE", headers: authHeaders(), body: JSON.stringify({ kind: item.kind, slug: item.slug, email: button.dataset.removeUser }) }); setDialogMessage("Access removed.", "success"); await refresh(); } catch (err) { setDialogMessage(err.message, "error"); button.disabled = false; } }));
|
||||
linkList.querySelectorAll("[data-link-token]").forEach(linkRow => {
|
||||
linkList.querySelectorAll("[data-link-token-hash]").forEach(linkRow => {
|
||||
const forever = linkRow.elements.forever, hours = linkRow.elements.hours; const sync = () => { hours.disabled = forever.checked; }; forever.addEventListener("change", sync); sync();
|
||||
linkRow.addEventListener("submit", async event => { event.preventDefault(); try { const expires_at = shareExpiry(hours.value, forever.checked); await api("/api/auth/resources/share-links", { method: "PUT", headers: authHeaders(), body: JSON.stringify({ kind: item.kind, slug: item.slug, token: linkRow.dataset.linkToken, permission: linkRow.elements.permission.value, expires_at }) }); setDialogMessage("Link updated.", "success"); await refresh(); } catch (err) { setDialogMessage(err.message, "error"); } });
|
||||
linkRow.querySelector("[data-revoke-link]").addEventListener("click", async () => { try { await api("/api/auth/resources/share-links", { method: "DELETE", headers: authHeaders(), body: JSON.stringify({ kind: item.kind, slug: item.slug, token: linkRow.dataset.linkToken }) }); setDialogMessage("Link revoked.", "success"); await refresh(); } catch (err) { setDialogMessage(err.message, "error"); } });
|
||||
linkRow.querySelector("[data-copy-link]")?.addEventListener("click", async event => {
|
||||
const button = event.currentTarget;
|
||||
const input = linkRow.querySelector(".share-link-inline input");
|
||||
try {
|
||||
button.disabled = true;
|
||||
await copyText(input.value);
|
||||
setDialogMessage("Link copied.", "success");
|
||||
} catch (err) {
|
||||
setDialogMessage(err.message, "error");
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
}
|
||||
});
|
||||
linkRow.addEventListener("submit", async event => { event.preventDefault(); try { const expires_at = shareExpiry(hours.value, forever.checked); await api("/api/auth/resources/share-links", { method: "PUT", headers: authHeaders(), body: JSON.stringify({ kind: item.kind, slug: item.slug, token_hash: linkRow.dataset.linkTokenHash, label: linkRow.elements.label.value, permission: linkRow.elements.permission.value, expires_at }) }); setDialogMessage("Link updated.", "success"); await refresh(); } catch (err) { setDialogMessage(err.message, "error"); } });
|
||||
linkRow.querySelector("[data-revoke-link]").addEventListener("click", async () => { try { await api("/api/auth/resources/share-links", { method: "DELETE", headers: authHeaders(), body: JSON.stringify({ kind: item.kind, slug: item.slug, token_hash: linkRow.dataset.linkTokenHash }) }); createdLinkValues.delete(linkRow.dataset.linkTokenHash); setDialogMessage("Link revoked.", "success"); await refresh(); } catch (err) { setDialogMessage(err.message, "error"); } });
|
||||
});
|
||||
};
|
||||
userForm.addEventListener("submit", async event => { event.preventDefault(); try { const result = await api("/api/auth/resources/sharing", { method: "POST", headers: authHeaders(), body: JSON.stringify({ kind: item.kind, slug: item.slug, recipients: userForm.recipients.value, permission: userForm.permission.value }) }); userForm.recipients.value = ""; setDialogMessage(result.confirmation_required ? "Invitation sent. Access will appear after the recipient accepts it." : "Access granted.", "success"); await refresh(); } catch (err) { setDialogMessage(err.message, "error"); } });
|
||||
linkForm.addEventListener("submit", async event => { event.preventDefault(); try { const expires_at = shareExpiry(linkForm.hours.value, linkForm.forever.checked); const result = await api("/api/auth/resources/share-links", { method: "POST", headers: authHeaders(), body: JSON.stringify({ kind: item.kind, slug: item.slug, permission: linkForm.permission.value, expires_at }) }); const absolute = new URL(result.url, location.origin).href; await copyText(absolute); setDialogMessage("Link created and copied. For security, it is shown only once.", "success"); await refresh(); } catch (err) { setDialogMessage(err.message, "error"); } });
|
||||
linkForm.addEventListener("submit", async event => {
|
||||
event.preventDefault();
|
||||
try {
|
||||
const expires_at = shareExpiry(linkForm.hours.value, linkForm.forever.checked);
|
||||
const result = await api("/api/auth/resources/share-links", { method: "POST", headers: authHeaders(), body: JSON.stringify({ kind: item.kind, slug: item.slug, label: linkForm.label.value, permission: linkForm.permission.value, expires_at }) });
|
||||
const absolute = new URL(result.url, location.origin).href;
|
||||
createdLinkValues.set(result.token_hash, absolute);
|
||||
linkForm.label.value = "";
|
||||
await refresh();
|
||||
try {
|
||||
await copyText(absolute);
|
||||
setDialogMessage("Link created, displayed below and copied.", "success");
|
||||
} catch {
|
||||
setDialogMessage("Link created and displayed below. Automatic copying failed; use the Copy button.", "warning");
|
||||
}
|
||||
} catch (err) {
|
||||
setDialogMessage(err.message, "error");
|
||||
}
|
||||
});
|
||||
dialog.showModal();
|
||||
try { await refresh(); } catch (err) { setDialogMessage(err.message, "error"); }
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user