/**
* Modal dialog for managing project or folder sharing.
*
* Supports per-user role assignment (owner/editor/viewer), user search,
* "anyone with link" access (owners only), ownership transfer, and
* optional email notifications.
*
* @param {string} entityId - Project ID or folder path.
* @param {object} server - Server instance (for token + baseUrl + backendUser).
* @param {'project'|'folder'} [type='project']
*/
export class ShareDialog {
/**
* @param {string} entityId - Project ID or folder path.
* @param {object} server - Server instance (for token, baseUrl, and backendUser).
* @param {'project'|'folder'} [type] - Entity type; defaults to 'project'.
*/
constructor(entityId, server, type = 'project') {
this.entityId = entityId;
this.server = server;
this.type = type;
/** Resolved permissions: {owners, editors, viewers, any_with_link, public} */
this._perms = { owners: [], editors: [], viewers: [], any_with_link: false, public: false };
this._isOwner = false;
this._searchTimer = null;
this._buildDOM();
document.body.appendChild(this.root);
this._load();
}
// ── API helpers ───────────────────────────────────────────────────────────
/** @returns {string} API permissions URL for the current entity. */
_permPath() {
return this.type === 'folder'
? `${this.server.baseUrl}/api/folders/${this.entityId}/permissions`
: `${this.server.baseUrl}/api/projects/${this.entityId}/permissions`;
}
/**
* @param {object} [extra] - additional headers to merge in
* @returns {Promise<object>}
*/
async _authHeaders(extra = {}) {
const token = await this.server.getToken();
return { ...(token ? { 'X-Auth-Token': token } : {}), ...extra };
}
// ── DOM construction ──────────────────────────────────────────────────────
/** Builds and appends the dialog scrim and root DOM elements. */
_buildDOM() {
this.scrim = document.createElement('div');
this.scrim.className = 'share-scrim';
this.scrim.addEventListener('click', () => this.close());
document.body.appendChild(this.scrim);
this.root = document.createElement('div');
this.root.className = 'share-dialog';
this.root.innerHTML = `
<div class="share-dialog-header">
<h2 class="share-dialog-title">Share</h2>
<button class="share-close-btn" title="Close">✕</button>
</div>
<div class="share-dialog-body" id="shareBody">
<div class="share-loading">Loading…</div>
</div>
<div class="share-status" id="shareStatus" style="display:none"></div>
`;
this.root.querySelector('.share-close-btn').addEventListener('click', () => this.close());
this._body = this.root.querySelector('#shareBody');
this._status = this.root.querySelector('#shareStatus');
document.body.appendChild(this.root);
}
// ── Load & render ─────────────────────────────────────────────────────────
/** Fetches current permissions from the server and triggers a re-render. */
async _load() {
try {
const res = await fetch(this._permPath(), {
headers: await this._authHeaders(),
credentials: 'include',
});
if (!res.ok) throw new Error('Failed to load permissions');
this._perms = await res.json();
} catch {
this._perms = { owners: [], editors: [], viewers: [], any_with_link: false, public: false };
}
const myId = this.server.backendUser?.id;
this._isOwner = (this._perms.owners || []).some(u => u.id === myId);
this._render();
}
/** Rebuilds the dialog body from current permissions state. */
_render() {
this._body.innerHTML = '';
this._body.appendChild(this._buildAddSection());
this._body.appendChild(this._buildPeopleSection());
if (this._isOwner) {
const linkSection = this._buildLinkSection();
if (linkSection) this._body.appendChild(linkSection);
}
if (this.type === 'project') {
this._body.appendChild(this._buildActionsSection());
}
}
// ── Add people section ────────────────────────────────────────────────────
/** @returns {HTMLElement} the "add people" search row section */
_buildAddSection() {
const wrap = document.createElement('div');
wrap.className = 'share-add-section';
const row = document.createElement('div');
row.className = 'share-add-row';
const input = document.createElement('input');
input.type = 'text';
input.className = 'share-search-input';
input.placeholder = 'Add people by name or email…';
input.autocomplete = 'off';
const roleSelect = document.createElement('select');
roleSelect.className = 'share-role-select share-role-select--add';
roleSelect.innerHTML = `
<option value="viewer">Viewer</option>
<option value="editor">Editor</option>
`;
const notifyWrap = document.createElement('label');
notifyWrap.className = 'share-notify-label';
notifyWrap.innerHTML = `<input type="checkbox" id="shareNotify" checked /> Notify`;
row.appendChild(input);
row.appendChild(roleSelect);
row.appendChild(notifyWrap);
const dropdown = document.createElement('div');
dropdown.className = 'share-search-dropdown';
dropdown.style.display = 'none';
wrap.appendChild(row);
wrap.appendChild(dropdown);
input.addEventListener('input', () => {
clearTimeout(this._searchTimer);
const q = input.value.trim();
if (!q) { dropdown.style.display = 'none'; return; }
this._searchTimer = setTimeout(() => this._runSearch(q, dropdown, input, roleSelect, notifyWrap), 280);
});
input.addEventListener('keydown', e => {
if (e.key === 'Escape') { dropdown.style.display = 'none'; input.value = ''; }
});
document.addEventListener('click', e => {
if (!wrap.contains(e.target)) dropdown.style.display = 'none';
}, { capture: true });
return wrap;
}
/**
* Searches for users matching the query and populates the dropdown.
* @param {string} query - Search string typed by the user.
* @param {HTMLElement} dropdown - Container element for search results.
* @param {HTMLElement} input - Text input to clear after selection.
* @param {HTMLElement} roleSelect - Role selector whose value is applied on selection.
* @param {HTMLElement} notifyWrap - Wrapper containing the notify checkbox.
*/
async _runSearch(query, dropdown, input, roleSelect, notifyWrap) {
try {
const res = await fetch(
`${this.server.baseUrl}/api/users/search?q=${encodeURIComponent(query)}`,
{ headers: await this._authHeaders(), credentials: 'include' }
);
if (!res.ok) return;
const results = await res.json();
// Filter out people who already have access
const existingIds = new Set([
...(this._perms.owners || []).map(u => u.id),
...(this._perms.editors || []).map(u => u.id),
...(this._perms.viewers || []).map(u => u.id),
]);
const filtered = results.filter(u => !existingIds.has(u.id));
dropdown.innerHTML = '';
if (!filtered.length) {
dropdown.innerHTML = '<div class="share-search-empty">No users found</div>';
dropdown.style.display = '';
return;
}
filtered.forEach(u => {
const item = document.createElement('div');
item.className = 'share-search-item';
item.innerHTML = `
<div class="share-search-avatar">${this._initials(u.display_name || u.email)}</div>
<div class="share-search-info">
<span class="share-search-name">${this._esc(u.display_name || '—')}</span>
<span class="share-search-email">${this._esc(u.email || '')}</span>
</div>
`;
item.style.setProperty('--avatar-bg', this._hashColor(u.id));
item.addEventListener('click', () => {
dropdown.style.display = 'none';
input.value = '';
const role = roleSelect.value;
const notify = notifyWrap.querySelector('input').checked;
this._addUser(u, role, notify);
});
dropdown.appendChild(item);
});
dropdown.style.display = '';
} catch {
/* ignore */
}
}
/**
* Grants the user access at the given role and refreshes the UI.
* @param {object} user - User object from search results.
* @param {string} role - Role to grant: 'editor' or 'viewer'.
* @param {boolean} notify - Whether to email the user about the new access.
*/
async _addUser(user, role, notify) {
const updated = this._clonePerms();
if (role === 'editor') {
updated.editors.push(user.id);
} else {
updated.viewers.push(user.id);
}
const ok = await this._putPerms(updated, notify);
if (ok) {
if (role === 'editor') {
this._perms.editors = [...(this._perms.editors || []), user];
} else {
this._perms.viewers = [...(this._perms.viewers || []), user];
}
this._render();
this._showStatus(`Added ${user.display_name || user.email}`);
}
}
// ── People list section ───────────────────────────────────────────────────
/** @returns {HTMLElement} the people list section */
_buildPeopleSection() {
const section = document.createElement('div');
section.className = 'share-people-section';
const allGroups = [
{ role: 'owner', users: this._perms.owners || [] },
{ role: 'editor', users: this._perms.editors || [] },
{ role: 'viewer', users: this._perms.viewers || [] },
];
const hasAnyone = allGroups.some(g => g.users.length > 0);
if (!hasAnyone) {
section.innerHTML = '<div class="share-people-empty">Not shared with anyone yet.</div>';
return section;
}
allGroups.forEach(({ role, users }) => {
users.forEach(u => {
section.appendChild(this._buildUserRow(u, role));
});
});
return section;
}
/**
* Builds a single user row with avatar, name, and role controls.
* @param {object} user - User object with id, display_name, and email.
* @param {string} role - Current role: 'owner', 'editor', or 'viewer'.
* @returns {HTMLElement}
*/
_buildUserRow(user, role) {
const row = document.createElement('div');
row.className = 'share-user-row';
const avatar = document.createElement('div');
avatar.className = 'share-user-avatar';
avatar.textContent = this._initials(user.display_name || user.email);
avatar.style.setProperty('--avatar-bg', this._hashColor(user.id));
const info = document.createElement('div');
info.className = 'share-user-info';
info.innerHTML = `
<span class="share-user-name">${this._esc(user.display_name || '—')}</span>
<span class="share-user-email">${this._esc(user.email || '')}</span>
`;
const controls = document.createElement('div');
controls.className = 'share-user-controls';
const myId = this.server.backendUser?.id;
const isSelf = user.id === myId;
if (role === 'owner') {
const badge = document.createElement('span');
badge.className = 'share-role-badge';
badge.textContent = 'Owner';
controls.appendChild(badge);
// Transfer ownership button (owners only, not for self)
if (this._isOwner && !isSelf) {
const transferBtn = document.createElement('button');
transferBtn.className = 'share-transfer-btn';
transferBtn.textContent = 'Transfer';
transferBtn.addEventListener('click', () => this._confirmTransfer(user));
controls.appendChild(transferBtn);
}
} else if (this._isOwner) {
const select = document.createElement('select');
select.className = 'share-role-select';
select.innerHTML = `
<option value="editor" ${role === 'editor' ? 'selected' : ''}>Editor</option>
<option value="viewer" ${role === 'viewer' ? 'selected' : ''}>Viewer</option>
`;
select.addEventListener('change', () => this._changeRole(user, role, select.value));
const removeBtn = document.createElement('button');
removeBtn.className = 'share-remove-btn';
removeBtn.title = 'Remove';
removeBtn.innerHTML = '✕';
removeBtn.addEventListener('click', () => this._removeUser(user, role));
controls.appendChild(select);
controls.appendChild(removeBtn);
} else {
const badge = document.createElement('span');
badge.className = 'share-role-badge share-role-badge--muted';
badge.textContent = role.charAt(0).toUpperCase() + role.slice(1);
controls.appendChild(badge);
}
row.appendChild(avatar);
row.appendChild(info);
row.appendChild(controls);
return row;
}
/**
* Changes a user's role and persists the update.
* @param {object} user - User object being updated.
* @param {string} oldRole - Current role before the change.
* @param {string} newRole - New role to assign.
*/
async _changeRole(user, oldRole, newRole) {
if (oldRole === newRole) return;
const updated = this._clonePerms();
updated[oldRole + 's'] = updated[oldRole + 's'].filter(id => id !== user.id);
updated[newRole + 's'].push(user.id);
const ok = await this._putPerms(updated, false);
if (ok) {
this._perms[oldRole + 's'] = (this._perms[oldRole + 's'] || []).filter(u => u.id !== user.id);
this._perms[newRole + 's'] = [...(this._perms[newRole + 's'] || []), user];
this._render();
this._showStatus(`${user.display_name || user.email} is now ${newRole}`);
}
}
/**
* Removes a user's access and persists the update.
* @param {object} user - User object to remove.
* @param {string} role - Current role of the user being removed.
*/
async _removeUser(user, role) {
const updated = this._clonePerms();
updated[role + 's'] = updated[role + 's'].filter(id => id !== user.id);
const ok = await this._putPerms(updated, false);
if (ok) {
this._perms[role + 's'] = (this._perms[role + 's'] || []).filter(u => u.id !== user.id);
this._render();
this._showStatus(`Removed ${user.display_name || user.email}`);
}
}
// ── Transfer ownership ────────────────────────────────────────────────────
/**
* Shows a confirmation modal before transferring ownership.
* @param {object} newOwner - the user to transfer ownership to
*/
_confirmTransfer(newOwner) {
const myId = this.server.backendUser?.id;
const myInfo = (this._perms.owners || []).find(u => u.id === myId);
const myName = myInfo?.display_name || myInfo?.email || 'you';
const modal = document.createElement('div');
modal.className = 'share-transfer-modal';
modal.innerHTML = `
<div class="share-transfer-card">
<h3>Transfer ownership?</h3>
<p>
Transfer ownership of this ${this.type} to
<strong>${this._esc(newOwner.display_name || newOwner.email)}</strong>?<br>
<strong>${this._esc(myName)}</strong> will be demoted to editor.
This cannot be undone without their consent.
</p>
<div class="share-transfer-actions">
<button class="share-transfer-cancel">Cancel</button>
<button class="share-transfer-confirm">Transfer</button>
</div>
</div>
`;
modal.querySelector('.share-transfer-cancel').addEventListener('click', () => modal.remove());
modal.querySelector('.share-transfer-confirm').addEventListener('click', async () => {
modal.remove();
await this._doTransfer(newOwner);
});
this.root.appendChild(modal);
}
/**
* Executes the ownership transfer and reloads permissions.
* @param {object} newOwner - the user to become the new owner
*/
async _doTransfer(newOwner) {
const myId = this.server.backendUser?.id;
const updated = this._clonePerms();
// New owner replaces current owner list entry; old owner demoted to editor server-side
updated.owners = [newOwner.id];
// Retain other existing owners too (multi-owner scenario)
const otherOwners = (this._perms.owners || []).filter(u => u.id !== myId && u.id !== newOwner.id);
updated.owners.push(...otherOwners.map(u => u.id));
// Remove newOwner from editors/viewers if present
updated.editors = updated.editors.filter(id => id !== newOwner.id);
updated.viewers = updated.viewers.filter(id => id !== newOwner.id);
const ok = await this._putPerms(updated, false);
if (ok) {
await this._load(); // Reload to reflect demotion server applied
this._showStatus('Ownership transferred');
}
}
// ── Anyone with link section ──────────────────────────────────────────────
/** @returns {HTMLElement} the "anyone with link" toggle section */
_buildLinkSection() {
const presUrl = this.type === 'project'
? `${window.location.origin}/presentation/${this.entityId}`
: `${window.location.origin}/presentation/folder/${this.entityId}`;
const section = document.createElement('div');
section.className = 'share-section';
const row = document.createElement('div');
row.className = 'share-row';
row.innerHTML = `
<div class="share-row-text">
<span class="share-label">Anyone with link</span>
<span class="share-desc">Anyone with this link can view without signing in.</span>
</div>
<label class="share-toggle">
<input type="checkbox" id="shareAnyWithLink" ${this._perms.any_with_link ? 'checked' : ''} />
<span class="share-toggle-track"></span>
</label>
`;
const linkSection = document.createElement('div');
linkSection.className = 'share-link-section';
linkSection.style.display = this._perms.any_with_link ? '' : 'none';
linkSection.innerHTML = `
<div class="share-link-label">Shareable link</div>
<div class="share-link-row">
<input class="share-link-input" type="text" readonly value="${this._esc(presUrl)}" />
<button class="share-link-copy">Copy</button>
</div>
`;
const toggle = row.querySelector('#shareAnyWithLink');
toggle.addEventListener('change', async () => {
toggle.disabled = true;
const newVal = toggle.checked;
const updated = this._clonePerms();
updated.any_with_link = newVal;
const ok = await this._putPerms(updated, false);
if (ok) {
this._perms.any_with_link = newVal;
linkSection.style.display = newVal ? '' : 'none';
} else {
toggle.checked = !newVal;
}
toggle.disabled = false;
});
const copyBtn = linkSection.querySelector('.share-link-copy');
copyBtn.addEventListener('click', () => {
navigator.clipboard.writeText(presUrl).then(() => {
copyBtn.textContent = 'Copied!';
setTimeout(() => { copyBtn.textContent = 'Copy'; }, 2000);
});
});
section.appendChild(row);
section.appendChild(linkSection);
return section;
}
// ── Open presentation button (projects only) ──────────────────────────────
/** @returns {HTMLElement} the actions footer section with the open-presentation button */
_buildActionsSection() {
const wrap = document.createElement('div');
wrap.className = 'share-actions';
const btn = document.createElement('button');
btn.className = 'share-open-btn';
btn.textContent = 'Open Presentation';
btn.addEventListener('click', () => {
window.open(`/presentation/${this.entityId}`, '_blank');
});
wrap.appendChild(btn);
return wrap;
}
// ── PUT helper ────────────────────────────────────────────────────────────
/**
* Convert internal {owners: [userObj]} shape to {owners: [id]} for the API.
* @returns {object} permissions payload with ID arrays
*/
_clonePerms() {
return {
owners: (this._perms.owners || []).map(u => u.id),
editors: (this._perms.editors || []).map(u => u.id),
viewers: (this._perms.viewers || []).map(u => u.id),
any_with_link: this._perms.any_with_link,
public: this._perms.public,
};
}
/**
* Sends updated permissions to the server.
* @param {object} permissions - Permissions payload with owner/editor/viewer ID arrays and link flags.
* @param {boolean} [notify] - Whether to notify affected users by email.
* @returns {Promise<boolean>}
*/
async _putPerms(permissions, notify = true) {
try {
const res = await fetch(this._permPath(), {
method: 'PUT',
headers: await this._authHeaders({ 'Content-Type': 'application/json' }),
credentials: 'include',
body: JSON.stringify({ permissions, notify }),
});
if (!res.ok) {
let err;
try {
err = await res.json();
} catch {
err = {};
}
this._showStatus(err.error || 'Could not update sharing settings.', true);
return false;
}
return true;
} catch {
this._showStatus('Could not update sharing settings.', true);
return false;
}
}
// ── Utilities ─────────────────────────────────────────────────────────────
/**
* Displays a transient status message at the bottom of the dialog.
* @param {string} msg - Message text to display.
* @param {boolean} [isError] - If true, renders in error color.
*/
_showStatus(msg, isError = false) {
this._status.textContent = msg;
this._status.style.display = '';
this._status.style.color = isError ? 'var(--danger)' : 'var(--success)';
clearTimeout(this._statusTimer);
this._statusTimer = setTimeout(() => { this._status.style.display = 'none'; }, 3000);
}
/**
* @param {string} name - Display name or email to derive initials from.
* @returns {string} 1-2 letter initials
*/
_initials(name) {
if (!name) return '?';
const parts = name.trim().split(/\s+/);
if (parts.length === 1) return parts[0][0].toUpperCase();
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
}
/**
* @param {string} id - User ID to hash into a color.
* @returns {string} deterministic HSL color
*/
_hashColor(id) {
let hash = 0;
for (let i = 0; i < (id || '').length; i++) hash = (hash * 31 + id.charCodeAt(i)) | 0;
const h = Math.abs(hash) % 360;
return `hsl(${h}, 55%, 45%)`;
}
/**
* @param {string} str - Raw string to escape.
* @returns {string} HTML-escaped string
*/
_esc(str) {
return String(str ?? '').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
}
/** Removes the dialog and scrim from the DOM. */
close() {
this.root.remove();
this.scrim.remove();
}
// ── Public: get all users with access (for avatar stacks) ────────────────
/** @returns {Array} all users with any level of access */
getSharedUsers() {
return [
...(this._perms.owners || []),
...(this._perms.editors || []),
...(this._perms.viewers || []),
];
}
}