refactor(share,grants): migrate expiration from legacy share into grants, simplify legacy share, normalize shareModal for better UX
This commit is contained in:
+312
-252
@@ -27,6 +27,16 @@ import { createUserVignette } from './userVignette.js';
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Format a YYYY-MM-DD date string for display ("Dec 31, 2026").
|
||||
* @param {string} dateStr
|
||||
* @returns {string}
|
||||
*/
|
||||
function _formatExpiryDate(dateStr) {
|
||||
const d = new Date(`${dateStr}T00:00:00`);
|
||||
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
|
||||
}
|
||||
|
||||
/** Permissions that belong to each role (must mirror the Rust DTO). */
|
||||
const ROLE_PERMISSIONS = {
|
||||
viewer: ['read'],
|
||||
@@ -101,6 +111,9 @@ const shareModal = {
|
||||
/** @type {ShareRoleEnum} */
|
||||
_stagedRole: 'viewer',
|
||||
|
||||
/** @type {string|null} — YYYY-MM-DD expiry for the next staged users batch */
|
||||
_stagedExpiry: null,
|
||||
|
||||
/** @type {HTMLElement|null} — body node injected into Modal */
|
||||
_bodyEl: null,
|
||||
|
||||
@@ -119,6 +132,7 @@ const shareModal = {
|
||||
this._newLinks = [];
|
||||
this._stagedUsers = [];
|
||||
this._stagedRole = 'viewer';
|
||||
this._stagedExpiry = null;
|
||||
|
||||
const title = `${i18n.t('share.shareOf', 'Share of:')} ${item.name}`;
|
||||
|
||||
@@ -130,6 +144,7 @@ const shareModal = {
|
||||
icon: 'fa-share-alt',
|
||||
content: this._bodyEl,
|
||||
confirmText: i18n.t('actions.apply', 'Apply'),
|
||||
confirmDisabled: true,
|
||||
onConfirm: () => {
|
||||
this._applyAll();
|
||||
} // intentionally discard Promise
|
||||
@@ -164,6 +179,17 @@ const shareModal = {
|
||||
Modal.close(false);
|
||||
},
|
||||
|
||||
// ── Apply-button state ─────────────────────────────────────────────────────
|
||||
|
||||
/** @returns {boolean} */
|
||||
_hasPendingChanges() {
|
||||
return this._localMembers.some((m) => m._op !== 'keep') || this._localLinks.some((e) => e._op !== 'keep') || this._newLinks.length > 0;
|
||||
},
|
||||
|
||||
_syncApplyBtn() {
|
||||
if (Modal.confirmBtn) Modal.confirmBtn.disabled = !this._hasPendingChanges();
|
||||
},
|
||||
|
||||
// ── Skeleton ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -262,6 +288,11 @@ const shareModal = {
|
||||
this._stagedRole = /** @type {ShareRoleEnum} */ (roleSelect.value);
|
||||
});
|
||||
|
||||
// ── Expiry chip ──────────────────────────────────────────────────────
|
||||
const expiryChip = this._buildExpiryChip(null, (v) => {
|
||||
this._stagedExpiry = v;
|
||||
});
|
||||
|
||||
// ── Add button ───────────────────────────────────────────────────────
|
||||
const addBtn = document.createElement('button');
|
||||
addBtn.className = 'smd-add-btn btn btn-secondary';
|
||||
@@ -316,6 +347,7 @@ const shareModal = {
|
||||
|
||||
row.appendChild(wrap);
|
||||
row.appendChild(roleSelect);
|
||||
row.appendChild(expiryChip);
|
||||
row.appendChild(addBtn);
|
||||
|
||||
return row;
|
||||
@@ -419,7 +451,7 @@ const shareModal = {
|
||||
/** @type {Grant} */
|
||||
const placeholderGrant = {
|
||||
id: '', // not yet persisted
|
||||
granted_at: 0,
|
||||
granted_at: '',
|
||||
granted_by: '',
|
||||
subject: { type: 'user', id: contact.id },
|
||||
permission: /** @type {import('../core/types.js').PermissionTypeEnum} */ (ROLE_PERMISSIONS[this._stagedRole][0]),
|
||||
@@ -429,7 +461,8 @@ const shareModal = {
|
||||
grant: placeholderGrant,
|
||||
_grants: [], // no server grants yet — nothing to revoke on remove
|
||||
role: this._stagedRole,
|
||||
_op: 'new'
|
||||
_op: 'new',
|
||||
expires_at: this._stagedExpiry
|
||||
});
|
||||
}
|
||||
this._stagedUsers = [];
|
||||
@@ -450,6 +483,7 @@ const shareModal = {
|
||||
_refreshMemberGroups() {
|
||||
const container = /** @type {HTMLElement|null} */ (document.getElementById('smd-member-groups'));
|
||||
if (container) this._renderMemberGroupsInto(container);
|
||||
this._syncApplyBtn();
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -521,6 +555,19 @@ const shareModal = {
|
||||
this._refreshMemberGroups();
|
||||
});
|
||||
|
||||
// ── Expiry chip ──────────────────────────────────────────────────────
|
||||
// Initialise entry.expires_at once from the representative grant so that
|
||||
// role-only changes preserve the current expiry across row rebuilds.
|
||||
if (!Object.hasOwn(entry, 'expires_at')) {
|
||||
const raw = entry.grant.expires_at ?? null;
|
||||
entry.expires_at = raw ? String(raw).slice(0, 10) : null;
|
||||
}
|
||||
const expiryChip = this._buildExpiryChip(entry.expires_at, (v) => {
|
||||
entry.expires_at = v;
|
||||
if (entry._op !== 'new') entry._op = 'change';
|
||||
this._syncApplyBtn();
|
||||
});
|
||||
|
||||
const removeBtn = document.createElement('button');
|
||||
removeBtn.className = 'smd-row-action';
|
||||
removeBtn.title = i18n.t('actions.remove', 'Remove');
|
||||
@@ -532,10 +579,162 @@ const shareModal = {
|
||||
|
||||
row.appendChild(vignette);
|
||||
row.appendChild(roleSelect);
|
||||
row.appendChild(expiryChip);
|
||||
row.appendChild(removeBtn);
|
||||
return row;
|
||||
},
|
||||
|
||||
// ── Expiry chip toggle ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Build a compact expiry chip that toggles to an inline date input on click.
|
||||
*
|
||||
* Chip states:
|
||||
* • "∞ No expiry" — dashed border, faint text (value is null)
|
||||
* • "⏱ Dec 31, 2026 ×" — solid border, with a clear button (value is set)
|
||||
*
|
||||
* @param {string|null} initialValue - YYYY-MM-DD or null
|
||||
* @param {(v: string|null) => void} onChange - called whenever the value changes
|
||||
* @returns {HTMLElement}
|
||||
*/
|
||||
_buildExpiryChip(initialValue, onChange) {
|
||||
let current = initialValue;
|
||||
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'smd-expiry-chip-wrap';
|
||||
|
||||
const chip = document.createElement('button');
|
||||
chip.type = 'button';
|
||||
|
||||
const dateInput = document.createElement('input');
|
||||
dateInput.type = 'date';
|
||||
dateInput.className = 'smd-expiry-date-input hidden';
|
||||
|
||||
const updateChip = () => {
|
||||
if (current) {
|
||||
chip.className = 'smd-expiry-chip smd-expiry-chip--set';
|
||||
chip.innerHTML =
|
||||
`<i class="fas fa-clock"></i> ${_formatExpiryDate(current)}` +
|
||||
`<span class="smd-expiry-chip-clear" title="${i18n.t('actions.clear', 'Clear')}">×</span>`;
|
||||
chip.querySelector('.smd-expiry-chip-clear')?.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
current = null;
|
||||
onChange(null);
|
||||
updateChip();
|
||||
});
|
||||
} else {
|
||||
chip.className = 'smd-expiry-chip';
|
||||
chip.innerHTML = `<i class="fas fa-infinity"></i> ${i18n.t('share.noExpiry', 'No expiry')}`;
|
||||
}
|
||||
};
|
||||
|
||||
chip.addEventListener('click', () => {
|
||||
chip.classList.add('hidden');
|
||||
if (current) dateInput.value = current;
|
||||
dateInput.classList.remove('hidden');
|
||||
dateInput.focus();
|
||||
});
|
||||
|
||||
const confirm = () => {
|
||||
const val = dateInput.value || null;
|
||||
current = val;
|
||||
onChange(val);
|
||||
dateInput.classList.add('hidden');
|
||||
chip.classList.remove('hidden');
|
||||
updateChip();
|
||||
};
|
||||
dateInput.addEventListener('blur', confirm);
|
||||
dateInput.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
confirm();
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
dateInput.classList.add('hidden');
|
||||
chip.classList.remove('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
updateChip();
|
||||
wrap.appendChild(chip);
|
||||
wrap.appendChild(dateInput);
|
||||
return wrap;
|
||||
},
|
||||
|
||||
/**
|
||||
* @param {boolean} initialHasPassword
|
||||
* @param {(v: string) => void} onChange '' = remove / clear, non-empty = set new password
|
||||
* @returns {HTMLElement}
|
||||
*/
|
||||
_buildPasswordChip(initialHasPassword, onChange) {
|
||||
let hasPassword = initialHasPassword;
|
||||
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'smd-expiry-chip-wrap';
|
||||
|
||||
const chip = document.createElement('button');
|
||||
chip.type = 'button';
|
||||
|
||||
const pwInput = document.createElement('input');
|
||||
pwInput.type = 'password';
|
||||
pwInput.className = 'smd-expiry-date-input hidden';
|
||||
pwInput.placeholder = i18n.t('dialogs.password', 'Password');
|
||||
pwInput.autocomplete = 'new-password';
|
||||
|
||||
const updateChip = () => {
|
||||
if (hasPassword) {
|
||||
chip.className = 'smd-expiry-chip smd-expiry-chip--set';
|
||||
chip.innerHTML =
|
||||
`<i class="fas fa-lock"></i> ${i18n.t('share.passwordProtected', 'Password')}` +
|
||||
`<span class="smd-expiry-chip-clear" title="${i18n.t('actions.clear', 'Clear')}">×</span>`;
|
||||
chip.querySelector('.smd-expiry-chip-clear')?.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
hasPassword = false;
|
||||
onChange('');
|
||||
updateChip();
|
||||
});
|
||||
} else {
|
||||
chip.className = 'smd-expiry-chip';
|
||||
chip.innerHTML = `<i class="fas fa-lock-open"></i> ${i18n.t('share.noPassword', 'No password')}`;
|
||||
}
|
||||
};
|
||||
|
||||
chip.addEventListener('click', () => {
|
||||
chip.classList.add('hidden');
|
||||
pwInput.value = '';
|
||||
pwInput.classList.remove('hidden');
|
||||
pwInput.focus();
|
||||
});
|
||||
|
||||
const confirm = () => {
|
||||
const val = pwInput.value;
|
||||
pwInput.classList.add('hidden');
|
||||
chip.classList.remove('hidden');
|
||||
if (val) {
|
||||
hasPassword = true;
|
||||
onChange(val);
|
||||
}
|
||||
updateChip();
|
||||
};
|
||||
|
||||
pwInput.addEventListener('blur', confirm);
|
||||
pwInput.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
confirm();
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
pwInput.classList.add('hidden');
|
||||
chip.classList.remove('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
updateChip();
|
||||
wrap.appendChild(chip);
|
||||
wrap.appendChild(pwInput);
|
||||
return wrap;
|
||||
},
|
||||
|
||||
// ── Links section ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -550,29 +749,72 @@ const shareModal = {
|
||||
title.textContent = i18n.t('share.publicLinks', 'Public links');
|
||||
section.appendChild(title);
|
||||
|
||||
section.appendChild(this._buildAddLinkRow());
|
||||
|
||||
const listEl = document.createElement('div');
|
||||
listEl.id = 'smd-links-list';
|
||||
this._renderLinksInto(listEl);
|
||||
section.appendChild(listEl);
|
||||
|
||||
const newLinkBtn = document.createElement('button');
|
||||
newLinkBtn.className = 'smd-new-link-btn';
|
||||
newLinkBtn.innerHTML = `<i class="fas fa-plus"></i> ${i18n.t('share.createLink', 'Create new public link')}`;
|
||||
newLinkBtn.id = 'smd-new-link-btn';
|
||||
return section;
|
||||
},
|
||||
|
||||
const newLinkForm = document.createElement('div');
|
||||
newLinkForm.id = 'smd-new-link-form';
|
||||
newLinkForm.className = 'smd-new-link-form hidden';
|
||||
newLinkForm.appendChild(this._buildNewLinkForm(newLinkBtn, newLinkForm));
|
||||
/**
|
||||
* Always-visible add-link row — mirrors the People search row layout.
|
||||
* Rebuilds itself after each Add to reset chip state.
|
||||
* @returns {HTMLElement}
|
||||
*/
|
||||
_buildAddLinkRow() {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'smd-search-row';
|
||||
row.id = 'smd-add-link-row';
|
||||
|
||||
newLinkBtn.addEventListener('click', () => {
|
||||
newLinkBtn.classList.add('hidden');
|
||||
newLinkForm.classList.remove('hidden');
|
||||
// Name input — wrapped in smd-search-wrap so it inherits flex:1
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'smd-search-wrap';
|
||||
const nameInput = document.createElement('input');
|
||||
nameInput.type = 'text';
|
||||
nameInput.className = 'smd-search-input';
|
||||
nameInput.placeholder = i18n.t('share.linkNamePlaceholder', 'Link name (optional)');
|
||||
wrap.appendChild(nameInput);
|
||||
|
||||
/** @type {string|null} */
|
||||
let stagedPassword = null;
|
||||
/** @type {string|null} */
|
||||
let stagedExpiry = null;
|
||||
|
||||
const pwChip = this._buildPasswordChip(false, (v) => {
|
||||
stagedPassword = v || null;
|
||||
});
|
||||
|
||||
section.appendChild(newLinkBtn);
|
||||
section.appendChild(newLinkForm);
|
||||
return section;
|
||||
const expChip = this._buildExpiryChip(null, (v) => {
|
||||
stagedExpiry = v;
|
||||
});
|
||||
|
||||
const addBtn = document.createElement('button');
|
||||
addBtn.className = 'smd-add-btn btn btn-secondary';
|
||||
addBtn.textContent = i18n.t('actions.add', 'Add');
|
||||
|
||||
addBtn.addEventListener('click', () => {
|
||||
/** @type {DraftLink} */
|
||||
const draft = {
|
||||
name: nameInput.value.trim(),
|
||||
password: stagedPassword,
|
||||
expires_at: stagedExpiry
|
||||
};
|
||||
this._newLinks.push(draft);
|
||||
this._refreshLinks();
|
||||
// Reset row (also resets chips via closure state)
|
||||
const fresh = this._buildAddLinkRow();
|
||||
row.replaceWith(fresh);
|
||||
});
|
||||
|
||||
row.appendChild(wrap);
|
||||
row.appendChild(pwChip);
|
||||
row.appendChild(expChip);
|
||||
row.appendChild(addBtn);
|
||||
|
||||
return row;
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -595,6 +837,7 @@ const shareModal = {
|
||||
_refreshLinks() {
|
||||
const container = /** @type {HTMLElement|null} */ (document.getElementById('smd-links-list'));
|
||||
if (container) this._renderLinksInto(container);
|
||||
this._syncApplyBtn();
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -603,87 +846,65 @@ const shareModal = {
|
||||
*/
|
||||
_buildLinkRow(entry) {
|
||||
const share = entry.share;
|
||||
const draft = entry._op === 'edit' ? entry._draft : null;
|
||||
|
||||
// Display values: prefer draft overrides when in edit-pending state
|
||||
const displayName = draft?.name ? draft.name : share.item_name || i18n.t('share.sharedLink', 'Shared link');
|
||||
const displayPw = draft ? draft.password !== null : share.has_password;
|
||||
const displayExp = draft ? draft.expires_at : share.expires_at ? fileSharing.formatExpirationDate(share.expires_at) : null;
|
||||
const ensureDraft = () => {
|
||||
if (!entry._draft) {
|
||||
entry._draft = {
|
||||
name: share.item_name || '',
|
||||
password: null,
|
||||
expires_at: share.expires_at ? new Date(share.expires_at * 1000).toISOString().slice(0, 10) : null
|
||||
};
|
||||
entry._op = 'edit';
|
||||
this._syncApplyBtn();
|
||||
}
|
||||
return entry._draft;
|
||||
};
|
||||
|
||||
// Derive current display values from draft if present, otherwise from share
|
||||
const currentHasPassword = entry._draft
|
||||
? entry._draft.password === ''
|
||||
? false
|
||||
: entry._draft.password
|
||||
? true
|
||||
: share.has_password
|
||||
: share.has_password;
|
||||
const currentExpiry = entry._draft ? entry._draft.expires_at : share.expires_at ? new Date(share.expires_at * 1000).toISOString().slice(0, 10) : null;
|
||||
|
||||
const row = document.createElement('div');
|
||||
row.className = 'smd-link-row';
|
||||
|
||||
const icon = document.createElement('div');
|
||||
icon.className = 'smd-link-icon';
|
||||
icon.innerHTML = '<i class="fas fa-link"></i>';
|
||||
|
||||
const info = document.createElement('div');
|
||||
info.className = 'smd-link-info';
|
||||
|
||||
const name = document.createElement('div');
|
||||
name.className = 'smd-link-name';
|
||||
name.textContent = displayName;
|
||||
name.textContent = entry._draft?.name || share.item_name || i18n.t('share.sharedLink', 'Shared link');
|
||||
|
||||
const tags = document.createElement('div');
|
||||
tags.className = 'smd-link-tags';
|
||||
if (displayPw) {
|
||||
const t = document.createElement('span');
|
||||
t.className = 'smd-link-tag';
|
||||
t.innerHTML = `<i class="fas fa-lock"></i> ${i18n.t('share.passwordProtected', 'Password')}`;
|
||||
tags.appendChild(t);
|
||||
}
|
||||
if (displayExp) {
|
||||
const t = document.createElement('span');
|
||||
t.className = 'smd-link-tag';
|
||||
t.innerHTML = `<i class="fas fa-clock"></i> ${displayExp}`;
|
||||
tags.appendChild(t);
|
||||
}
|
||||
|
||||
info.appendChild(name);
|
||||
if (tags.children.length) info.appendChild(tags);
|
||||
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'smd-link-actions';
|
||||
|
||||
// Copy
|
||||
const copyBtn = document.createElement('button');
|
||||
copyBtn.className = 'smd-row-action';
|
||||
copyBtn.title = i18n.t('actions.copy', 'Copy');
|
||||
copyBtn.title = i18n.t('actions.copy', 'Copy link');
|
||||
copyBtn.innerHTML = '<i class="fas fa-copy"></i>';
|
||||
copyBtn.addEventListener('click', () => fileSharing.copyLinkToClipboard(share.url));
|
||||
|
||||
// Edit
|
||||
const editBtn = document.createElement('button');
|
||||
editBtn.className = 'smd-row-action';
|
||||
editBtn.title = i18n.t('actions.edit', 'Edit');
|
||||
editBtn.innerHTML = '<i class="fas fa-pencil-alt"></i>';
|
||||
editBtn.addEventListener('click', () => {
|
||||
const panel = row.nextElementSibling;
|
||||
if (panel?.classList.contains('smd-edit-panel')) {
|
||||
panel.classList.toggle('hidden');
|
||||
} else {
|
||||
const editPanel = this._buildEditPanel(entry, row);
|
||||
row.after(editPanel);
|
||||
}
|
||||
const pwChip = this._buildPasswordChip(currentHasPassword, (v) => {
|
||||
ensureDraft().password = v;
|
||||
});
|
||||
|
||||
const expChip = this._buildExpiryChip(currentExpiry, (v) => {
|
||||
ensureDraft().expires_at = v;
|
||||
});
|
||||
|
||||
// Delete
|
||||
const delBtn = document.createElement('button');
|
||||
delBtn.className = 'smd-row-action';
|
||||
delBtn.title = i18n.t('actions.delete', 'Delete');
|
||||
delBtn.innerHTML = '<i class="fas fa-trash-alt"></i>';
|
||||
delBtn.innerHTML = '<i class="fas fa-times"></i>';
|
||||
delBtn.addEventListener('click', () => {
|
||||
entry._op = 'remove';
|
||||
this._refreshLinks();
|
||||
});
|
||||
|
||||
actions.appendChild(copyBtn);
|
||||
actions.appendChild(editBtn);
|
||||
actions.appendChild(delBtn);
|
||||
|
||||
row.appendChild(icon);
|
||||
row.appendChild(info);
|
||||
row.appendChild(actions);
|
||||
row.appendChild(name);
|
||||
row.appendChild(copyBtn);
|
||||
row.appendChild(pwChip);
|
||||
row.appendChild(expChip);
|
||||
row.appendChild(delBtn);
|
||||
return row;
|
||||
},
|
||||
|
||||
@@ -695,42 +916,21 @@ const shareModal = {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'smd-link-row';
|
||||
|
||||
const icon = document.createElement('div');
|
||||
icon.className = 'smd-link-icon';
|
||||
icon.innerHTML = '<i class="fas fa-link"></i>';
|
||||
|
||||
const info = document.createElement('div');
|
||||
info.className = 'smd-link-info';
|
||||
|
||||
const name = document.createElement('div');
|
||||
name.className = 'smd-link-name';
|
||||
name.textContent = draft.name || i18n.t('share.newLink', 'New link');
|
||||
|
||||
const tags = document.createElement('div');
|
||||
tags.className = 'smd-link-tags';
|
||||
if (draft.password) {
|
||||
const t = document.createElement('span');
|
||||
t.className = 'smd-link-tag';
|
||||
t.innerHTML = `<i class="fas fa-lock"></i> ${i18n.t('share.passwordProtected', 'Password')}`;
|
||||
tags.appendChild(t);
|
||||
}
|
||||
if (draft.expires_at) {
|
||||
const t = document.createElement('span');
|
||||
t.className = 'smd-link-tag';
|
||||
t.innerHTML = `<i class="fas fa-clock"></i> ${draft.expires_at}`;
|
||||
tags.appendChild(t);
|
||||
}
|
||||
|
||||
const pending = document.createElement('span');
|
||||
pending.className = 'smd-link-tag';
|
||||
pending.textContent = i18n.t('share.pending', 'Pending');
|
||||
tags.appendChild(pending);
|
||||
|
||||
info.appendChild(name);
|
||||
if (tags.children.length) info.appendChild(tags);
|
||||
const pwChip = this._buildPasswordChip(!!draft.password, (v) => {
|
||||
draft.password = v || null;
|
||||
});
|
||||
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'smd-link-actions';
|
||||
const expChip = this._buildExpiryChip(draft.expires_at, (v) => {
|
||||
draft.expires_at = v;
|
||||
});
|
||||
|
||||
const delBtn = document.createElement('button');
|
||||
delBtn.className = 'smd-row-action';
|
||||
@@ -741,158 +941,14 @@ const shareModal = {
|
||||
this._refreshLinks();
|
||||
});
|
||||
|
||||
actions.appendChild(delBtn);
|
||||
row.appendChild(icon);
|
||||
row.appendChild(info);
|
||||
row.appendChild(actions);
|
||||
row.appendChild(name);
|
||||
row.appendChild(pending);
|
||||
row.appendChild(pwChip);
|
||||
row.appendChild(expChip);
|
||||
row.appendChild(delBtn);
|
||||
return row;
|
||||
},
|
||||
|
||||
/**
|
||||
* @param {LinkEntry} entry
|
||||
* @param {HTMLElement} row
|
||||
* @returns {HTMLElement}
|
||||
*/
|
||||
_buildEditPanel(entry, row) {
|
||||
const panel = document.createElement('div');
|
||||
panel.className = 'smd-edit-panel';
|
||||
|
||||
const pwLabel = document.createElement('label');
|
||||
pwLabel.textContent = i18n.t('dialogs.password', 'Password');
|
||||
const pwInput = document.createElement('input');
|
||||
pwInput.type = 'password';
|
||||
pwInput.className = 'smd-edit-input';
|
||||
pwInput.placeholder = i18n.t('share.passwordPlaceholder', 'Leave empty to keep unchanged');
|
||||
|
||||
const expLabel = document.createElement('label');
|
||||
expLabel.textContent = i18n.t('dialogs.expiration', 'Expiration date');
|
||||
const expInput = document.createElement('input');
|
||||
expInput.type = 'date';
|
||||
expInput.className = 'smd-edit-input';
|
||||
if (entry.share.expires_at) {
|
||||
expInput.value = new Date(entry.share.expires_at * 1000).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
const actionsDiv = document.createElement('div');
|
||||
actionsDiv.className = 'smd-edit-panel-actions';
|
||||
|
||||
const cancelBtn = document.createElement('button');
|
||||
cancelBtn.className = 'btn btn-secondary';
|
||||
cancelBtn.textContent = i18n.t('actions.cancel', 'Cancel');
|
||||
cancelBtn.addEventListener('click', () => panel.remove());
|
||||
|
||||
const saveBtn = document.createElement('button');
|
||||
saveBtn.className = 'btn btn-primary';
|
||||
saveBtn.textContent = i18n.t('actions.save', 'Save');
|
||||
saveBtn.addEventListener('click', () => {
|
||||
entry._op = 'edit';
|
||||
entry._draft = {
|
||||
name: entry.share.item_name || '',
|
||||
password: pwInput.value || null,
|
||||
expires_at: expInput.value || null
|
||||
};
|
||||
panel.remove();
|
||||
this._refreshLinks();
|
||||
});
|
||||
|
||||
actionsDiv.appendChild(cancelBtn);
|
||||
actionsDiv.appendChild(saveBtn);
|
||||
|
||||
panel.appendChild(pwLabel);
|
||||
panel.appendChild(pwInput);
|
||||
panel.appendChild(expLabel);
|
||||
panel.appendChild(expInput);
|
||||
panel.appendChild(actionsDiv);
|
||||
|
||||
void row; // row is unused — panel is inserted via row.after() in caller
|
||||
return panel;
|
||||
},
|
||||
|
||||
/**
|
||||
* @param {HTMLButtonElement} newLinkBtn
|
||||
* @param {HTMLElement} formWrapper
|
||||
* @returns {HTMLElement}
|
||||
*/
|
||||
_buildNewLinkForm(newLinkBtn, formWrapper) {
|
||||
const inner = document.createElement('div');
|
||||
|
||||
const nameLabel = document.createElement('label');
|
||||
nameLabel.textContent = i18n.t('share.linkName', 'Link name');
|
||||
const nameInput = document.createElement('input');
|
||||
nameInput.type = 'text';
|
||||
nameInput.className = 'smd-edit-input';
|
||||
nameInput.placeholder = i18n.t('share.linkNamePlaceholder', 'Optional name');
|
||||
|
||||
const pwToggleLabel = document.createElement('label');
|
||||
pwToggleLabel.className = 'smd-pw-toggle';
|
||||
const pwCheckbox = document.createElement('input');
|
||||
pwCheckbox.type = 'checkbox';
|
||||
pwToggleLabel.appendChild(pwCheckbox);
|
||||
pwToggleLabel.appendChild(document.createTextNode(` ${i18n.t('share.addPassword', 'Add password')}`));
|
||||
|
||||
const pwInput = document.createElement('input');
|
||||
pwInput.type = 'password';
|
||||
pwInput.className = 'smd-edit-input hidden';
|
||||
pwInput.placeholder = i18n.t('dialogs.password', 'Password');
|
||||
pwCheckbox.addEventListener('change', () => {
|
||||
pwInput.classList.toggle('hidden', !pwCheckbox.checked);
|
||||
});
|
||||
|
||||
const expLabel = document.createElement('label');
|
||||
expLabel.textContent = i18n.t('dialogs.expiration', 'Expiration date');
|
||||
const expInput = document.createElement('input');
|
||||
expInput.type = 'date';
|
||||
expInput.className = 'smd-edit-input';
|
||||
|
||||
const actionsDiv = document.createElement('div');
|
||||
actionsDiv.className = 'smd-new-link-form-actions';
|
||||
|
||||
const cancelBtn = document.createElement('button');
|
||||
cancelBtn.className = 'btn btn-secondary';
|
||||
cancelBtn.textContent = i18n.t('actions.cancel', 'Cancel');
|
||||
cancelBtn.addEventListener('click', () => {
|
||||
formWrapper.classList.add('hidden');
|
||||
newLinkBtn.classList.remove('hidden');
|
||||
});
|
||||
|
||||
const addBtn = document.createElement('button');
|
||||
addBtn.className = 'btn btn-primary';
|
||||
addBtn.textContent = i18n.t('share.addLink', 'Add link');
|
||||
addBtn.addEventListener('click', () => {
|
||||
/** @type {DraftLink} */
|
||||
const draft = {
|
||||
name: nameInput.value.trim(),
|
||||
password: pwCheckbox.checked ? pwInput.value || null : null,
|
||||
expires_at: expInput.value || null
|
||||
};
|
||||
this._newLinks.push(draft);
|
||||
this._refreshLinks();
|
||||
|
||||
// Reset form
|
||||
nameInput.value = '';
|
||||
pwCheckbox.checked = false;
|
||||
pwInput.value = '';
|
||||
pwInput.classList.add('hidden');
|
||||
expInput.value = '';
|
||||
|
||||
formWrapper.classList.add('hidden');
|
||||
newLinkBtn.classList.remove('hidden');
|
||||
});
|
||||
|
||||
actionsDiv.appendChild(cancelBtn);
|
||||
actionsDiv.appendChild(addBtn);
|
||||
|
||||
inner.appendChild(nameLabel);
|
||||
inner.appendChild(nameInput);
|
||||
inner.appendChild(pwToggleLabel);
|
||||
inner.appendChild(pwInput);
|
||||
inner.appendChild(expLabel);
|
||||
inner.appendChild(expInput);
|
||||
inner.appendChild(actionsDiv);
|
||||
|
||||
return inner;
|
||||
},
|
||||
|
||||
// ── Apply ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -911,6 +967,8 @@ const shareModal = {
|
||||
try {
|
||||
// ── Grants ─────────────────────────────────────────────────────────
|
||||
for (const m of this._localMembers) {
|
||||
// Convert YYYY-MM-DD from date input to ISO-8601 datetime (midnight UTC).
|
||||
const expiresIso = m.expires_at ? new Date(`${m.expires_at}T00:00:00Z`).toISOString() : null;
|
||||
if (m._op === 'remove') {
|
||||
// Revoke every individual grant for this subject (one per permission).
|
||||
for (const g of m._grants) {
|
||||
@@ -920,13 +978,15 @@ const shareModal = {
|
||||
await grants.updateRole({
|
||||
subject: { type: m.grant.subject.type, id: m.grant.subject.id },
|
||||
resource: { type: itemType, id: item.id },
|
||||
role: m.role
|
||||
role: m.role,
|
||||
expires_at: expiresIso
|
||||
});
|
||||
} else if (m._op === 'new') {
|
||||
await grants.createGrant({
|
||||
subject: { type: m.grant.subject.type, id: m.grant.subject.id },
|
||||
resource: { type: itemType, id: item.id },
|
||||
role: m.role
|
||||
role: m.role,
|
||||
expires_at: expiresIso
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user