Files
Oxicloud/static/js/components/shareModal.js
T
Edouard Vanbelle 72129af0bd refactor(role): use grant only
- remove permission centric mode
    - finalize migration drop all tables with permissions
    - ensure roles are ENUM (owner is always displayed first)
2026-06-18 02:47:06 +02:00

1158 lines
47 KiB
JavaScript

// @ts-check
/**
* ShareModal — unified sharing dialog for files and folders.
*
* Covers two areas:
* • People (user-to-user grants via `/api/grants`)
* • Public links (via `/api/shares`)
*
* All mutations are staged locally and committed only when the user clicks
* Apply. The only immediate action is Copy Link (clipboard).
*
* The dialog shell (overlay, animation, header, footer, Escape/click-outside
* handling) is delegated entirely to `Modal.openPanel()`.
*/
import { ui } from '../app/ui.js';
import { i18n } from '../core/i18n.js';
import { fileSharing } from '../features/sharing/fileSharing.js';
import { addressBook, SYSTEM_BOOK_ID } from '../model/addressBook.js';
import { grants } from '../model/grants.js';
import { groups } from '../model/groups.js';
import { systemUsers } from '../model/systemUsers.js';
import { buildExpiryChip } from '../utils/expiryChip.js';
import { buildPasswordChip } from '../utils/passwordChip.js';
import { groupDisplayName, groupIconClass, groupIconClassByVirtual } from './groupDisplay.js';
import { createGroupVignette } from './groupVignette.js';
import { Modal } from './modal.js';
import { createPendingEmailVignette } from './pendingEmailVignette.js';
import { createUserVignette } from './userVignette.js';
/** @import {FileItem, FolderItem, Grant, ContactItem, MemberEntry, LinkEntry, DraftLink, ShareRoleEnum} from '../core/types.js' */
/**
* A ReBAC subject group surfaced by `/api/groups/search`. Shape is a
* deliberate superset of `ContactItem` so the staging / chip / commit code
* paths can treat both uniformly, discriminating on the `_kind` field.
*
* @typedef {Object} GroupSuggestion
* @property {string} id
* @property {string} name
* @property {boolean} is_virtual
* @property {'group'} _kind
*/
/**
* Synthetic "invite by email" suggestion injected at the bottom of the
* autocomplete dropdown when the query parses as an email and matches
* no existing contact. Same overall shape as `GroupSuggestion` so the
* staging / chip / commit paths can treat all three suggestion kinds
* uniformly via the `_kind` discriminator.
*
* `id` here is the email itself — it's a stable dedup key pre-resolution.
* The server replaces it with a real user UUID on Apply.
*
* @typedef {Object} EmailSuggestion
* @property {string} id Lowercased trimmed email (also the dedup key).
* @property {string} email Display form (lowercased trimmed).
* @property {'email'} _kind
*/
/**
* Permissive client-side email regex — matches anything with at least
* one non-whitespace local-part, an `@`, and a domain with a dot.
* The server's `normalize_email` is the authority; this is just enough
* to decide whether to surface the synthetic "invite by email"
* suggestion in the dropdown.
*
* @param {string} q
* @returns {boolean}
*/
function _looksLikeEmail(q) {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(q);
}
/**
* Fetch up to ~8 ReBAC subject groups whose name matches `q`. Authenticated
* endpoint; returns `[]` on any failure so the autocomplete degrades to
* contacts-only rather than breaking the dialog.
* @param {string} q
* @returns {Promise<GroupSuggestion[]>}
*/
async function _searchGroups(q) {
try {
const res = await fetch(`/api/groups/search?q=${encodeURIComponent(q)}&limit=8`, {
credentials: 'include'
});
if (!res.ok) return [];
/** @type {Array<{id:string,name:string,is_virtual:boolean}>} */
const items = await res.json();
return items.map((g) => ({
id: g.id,
name: g.name,
is_virtual: !!g.is_virtual,
_kind: /** @type {'group'} */ ('group')
}));
} catch {
return [];
}
}
/**
* Pick the displayed role for a member row. Server-side every Grant
* carries an explicit role since the cleanup PR, so this just reads it.
* The server may emit `commenter` or `contributor` (full enum), but the
* picker only exposes Viewer/Editor/Owner — collapse the two unexposed
* roles to the closest neighbour so the UI never renders an unknown
* option.
* @param {Grant[]} subjectGrants
* @returns {ShareRoleEnum}
*/
function _roleFromGrants(subjectGrants) {
const role = subjectGrants[0]?.role;
if (role === 'owner' || role === 'editor' || role === 'viewer') return role;
if (role === 'commenter') return 'viewer';
if (role === 'contributor') return 'editor';
return 'viewer';
}
/**
* Group grants by subject id and return one MemberEntry per unique subject.
* @param {Grant[]} grantList
* @returns {MemberEntry[]}
*/
function _buildMembers(grantList) {
/** @type {Map<string, Grant[]>} */
const bySubject = new Map();
for (const g of grantList) {
// Token grants represent public-link access — they belong in the Links
// section, not the People section.
if (g.subject.type === 'token') continue;
const key = g.subject.id;
if (!bySubject.has(key)) bySubject.set(key, []);
bySubject.get(key).push(g);
}
/** @type {MemberEntry[]} */
const members = [];
for (const subjectGrants of bySubject.values()) {
members.push({
grant: subjectGrants[0], // representative grant (used for subject/resource info)
_grants: subjectGrants, // all grants — needed to revoke every permission on remove
role: _roleFromGrants(subjectGrants),
_op: 'keep'
});
}
return members;
}
// ── Component ──────────────────────────────────────────────────────────────────
const shareModal = {
// ── State ──────────────────────────────────────────────────────────────────
/** @type {FileItem|FolderItem|null} */
_item: null,
/** @type {'file'|'folder'} */
_itemType: 'file',
/** @type {MemberEntry[]} */
_localMembers: [],
/** @type {LinkEntry[]} */
_localLinks: [],
/** @type {DraftLink[]} */
_newLinks: [],
/** @type {Array<ContactItem | GroupSuggestion | EmailSuggestion>} */
_stagedUsers: [],
/** @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,
/** @type {(() => void)|null} — called after changes are successfully committed */
_onApplied: null,
// ── Public API ─────────────────────────────────────────────────────────────
/**
* Open the share modal for a file or folder.
* @param {FileItem|FolderItem} item
* @param {'file'|'folder'} itemType
* @param {(() => void)=} onApplied - called after changes are successfully committed
*/
async open(item, itemType, onApplied) {
this._item = item;
this._itemType = itemType;
this._onApplied = onApplied ?? null;
this._localMembers = [];
this._localLinks = [];
this._newLinks = [];
this._stagedUsers = [];
this._stagedRole = 'viewer';
this._stagedExpiry = null;
const title = `${i18n.t('share.shareOf', 'Share of:')} ${item.name}`;
// Build body with loading skeleton
this._bodyEl = this._buildSkeleton();
Modal.openPanel({
title,
icon: 'fa-share-alt',
content: this._bodyEl,
confirmText: i18n.t('actions.apply', 'Apply'),
confirmDisabled: true,
onConfirm: () => {
this._applyAll();
} // intentionally discard Promise
});
// Prefetch system users in background so tooltips resolve instantly.
systemUsers.prefetch();
// Load data
try {
const [grantList, linkList] = await Promise.all([
grants.fetchGrantsForResource(itemType, item.id),
fileSharing.getSharedLinksForItem(item.id, itemType)
]);
this._localMembers = _buildMembers(grantList);
this._localLinks = linkList.map((share) => /** @type {LinkEntry} */ ({ share, _op: 'keep', _draft: null }));
// Group subjects in grants only carry their UUID — resolve full
// GroupItem records so member rows render the localised name and
// pick the correct icon (virtual groups get people-roof via
// `groupIconClass`).
const groupIds = new Set(this._localMembers.filter((m) => m.grant.subject.type === 'group').map((m) => m.grant.subject.id));
if (groupIds.size > 0) {
const resolved = await groups.resolveGroups(groupIds);
for (const m of this._localMembers) {
if (m.grant.subject.type === 'group') {
const g = resolved[m.grant.subject.id];
if (g) {
m._displayName = groupDisplayName(g);
m._isVirtual = g.is_virtual;
} else {
m._displayName = m.grant.subject.id;
}
}
}
}
} catch (err) {
console.error('shareModal: load error', err);
}
// Swap skeleton → real content
if (this._bodyEl) {
this._bodyEl.replaceChildren(...this._buildContent());
}
},
/**
* Close the modal (delegates to Modal.close).
*/
close() {
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 ───────────────────────────────────────────────────────────────
/**
* @returns {HTMLElement}
*/
_buildSkeleton() {
const body = document.createElement('div');
body.className = 'smd-body';
const skel = document.createElement('div');
skel.className = 'smd-skeleton';
for (const cls of ['smd-skeleton-line smd-skeleton-line--short', 'smd-skeleton-line smd-skeleton-line--medium', 'smd-skeleton-line']) {
const line = document.createElement('div');
line.className = cls;
skel.appendChild(line);
}
body.appendChild(skel);
return body;
},
// ── Content builder ────────────────────────────────────────────────────────
/**
* Build the two sections (People + Links) as an array of elements.
* @returns {HTMLElement[]}
*/
_buildContent() {
return [this._buildPeopleSection(), this._buildLinksSection()];
},
// ── People section ─────────────────────────────────────────────────────────
/**
* @returns {HTMLElement}
*/
_buildPeopleSection() {
const section = document.createElement('div');
section.className = 'smd-section';
const title = document.createElement('div');
title.className = 'smd-section-title';
title.textContent = i18n.t('share.people', 'People');
section.appendChild(title);
if (addressBook.isSystemAvailable()) {
section.appendChild(this._buildSearchRow());
section.appendChild(this._buildChipsRow());
} else {
const note = document.createElement('p');
note.className = 'smd-directory-unavailable';
note.textContent = i18n.t('share.directoryUnavailable', 'User directory unavailable');
section.appendChild(note);
}
section.appendChild(this._buildMemberGroups());
return section;
},
/**
* @returns {HTMLElement}
*/
_buildSearchRow() {
const row = document.createElement('div');
row.className = 'smd-search-row';
// ── Search input + dropdown ──────────────────────────────────────────
const wrap = document.createElement('div');
wrap.className = 'smd-search-wrap';
const input = document.createElement('input');
input.type = 'text';
input.className = 'smd-search-input';
input.placeholder = i18n.t('share.searchPlaceholder', 'Search people…');
input.autocomplete = 'off';
const dropdown = document.createElement('div');
dropdown.className = 'smd-suggestions hidden';
wrap.appendChild(input);
wrap.appendChild(dropdown);
// ── Role select ──────────────────────────────────────────────────────
const roleSelect = document.createElement('select');
roleSelect.className = 'smd-role-select';
for (const [val, label] of [
['viewer', i18n.t('share.role.canView', 'Can view')],
['editor', i18n.t('share.role.canEdit', 'Can edit')],
['owner', i18n.t('share.role.canManage', 'Can manage')]
]) {
const opt = document.createElement('option');
opt.value = val;
opt.textContent = label;
if (val === this._stagedRole) opt.selected = true;
roleSelect.appendChild(opt);
}
roleSelect.addEventListener('change', () => {
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';
addBtn.textContent = i18n.t('actions.add', 'Add');
addBtn.disabled = true;
// Search debounce
/** @type {ReturnType<typeof setTimeout>|null} */
let debounce = null;
input.addEventListener('input', () => {
if (debounce) clearTimeout(debounce);
const q = input.value.trim();
if (!q) {
dropdown.classList.add('hidden');
dropdown.replaceChildren();
return;
}
debounce = setTimeout(async () => {
// Search contacts (users) and ReBAC subject groups in parallel.
// Group results are tagged with `_kind='group'` so the rest of
// the dialog can render and commit them as group subjects.
const [contacts, groupItems] = await Promise.all([addressBook.searchContacts(q, [SYSTEM_BOOK_ID]), _searchGroups(q)]);
// Filter out the currently logged-in user — they cannot share with themselves
const currentUserId = (() => {
try {
return /** @type {{id?:string}} */ (JSON.parse(localStorage.getItem('oxicloud_user') ?? '{}'))?.id ?? null;
} catch {
return null;
}
})();
const filtered = currentUserId ? contacts.filter((c) => c.id !== currentUserId) : contacts;
// Synthesize an "invite by email" row when the query parses
// as an email AND no existing contact already matches that
// address (we don't want to compete with the existing
// contact suggestion). Lowercased+trimmed for the dedup
// key — same shape the server applies via normalize_email.
/** @type {EmailSuggestion[]} */
let emailItems = [];
if (_looksLikeEmail(q)) {
const normalised = q.trim().toLowerCase();
const existing = filtered.some((c) => (c.email ?? []).some((e) => e.email.toLowerCase() === normalised));
if (!existing) {
emailItems = [{ id: normalised, email: normalised, _kind: 'email' }];
}
}
// Groups first (they're a smaller, distinctively-iconed set),
// then contacts, then the email-invite suggestion at the
// bottom (it's the catch-all when nothing else matches).
// Cap at 8 combined.
const combined = [...groupItems, ...filtered, ...emailItems].slice(0, 8);
this._renderSuggestions(dropdown, combined, (item) => {
this._stageUser(item, input, dropdown, addBtn);
});
}, 200);
});
// Close dropdown on click outside
document.addEventListener(
'click',
(e) => {
if (!wrap.contains(/** @type {Node} */ (e.target))) {
dropdown.classList.add('hidden');
}
},
{ once: false }
);
addBtn.addEventListener('click', () => {
if (this._stagedUsers.length === 0) return;
this._commitStagedUsers();
addBtn.disabled = true;
});
row.appendChild(wrap);
row.appendChild(roleSelect);
row.appendChild(expiryChip);
row.appendChild(addBtn);
return row;
},
/**
* @param {HTMLElement} container
* @param {Array<ContactItem | GroupSuggestion | EmailSuggestion>} results
* @param {(c: ContactItem | GroupSuggestion | EmailSuggestion) => void} onSelect
*/
_renderSuggestions(container, results, onSelect) {
container.replaceChildren();
if (results.length === 0) {
container.classList.add('hidden');
return;
}
results.forEach((c) => {
const item = document.createElement('div');
item.className = 'smd-suggestion-item';
item.tabIndex = 0;
if (c._kind === 'group') {
const g = /** @type {GroupSuggestion} */ (c);
item.appendChild(createGroupVignette(groupDisplayName(g), 'sm', { icon: groupIconClass(g) }));
} else if (c._kind === 'email') {
const e = /** @type {EmailSuggestion} */ (c);
item.classList.add('smd-suggestion-item--email');
item.appendChild(createPendingEmailVignette(e.email, 'sm'));
const hint = document.createElement('span');
hint.className = 'smd-suggestion-hint';
hint.textContent = i18n.t('share.inviteByEmail', 'Invite by email — invitation will be sent');
item.appendChild(hint);
} else {
item.appendChild(createUserVignette(c.id, 'sm', { showEmail: true }));
}
const select = () => onSelect(c);
item.addEventListener('click', select);
item.addEventListener('keydown', (e) => {
if (e.key === 'Enter') select();
});
container.appendChild(item);
});
container.classList.remove('hidden');
},
/**
* @param {ContactItem | GroupSuggestion | EmailSuggestion} contact
* @param {HTMLInputElement} inputEl
* @param {HTMLElement} dropdown
* @param {HTMLButtonElement} addBtn
*/
_stageUser(contact, inputEl, dropdown, addBtn) {
// Idempotent: skip duplicates and already-existing members. Match on
// id *and* kind so a user / group / email-invite sharing the same
// string value (unlikely but harmless) wouldn't shadow each other.
const kind = contact._kind === 'group' ? 'group' : contact._kind === 'email' ? 'email' : 'user';
const alreadyMember = this._localMembers.some((m) => {
if (m._op === 'remove') return false;
// Match against existing committed members on (type, id) — for
// email-staged members, the dedup happens via `_invitedEmail`.
if (kind === 'email') {
return m._invitedEmail?.toLowerCase() === contact.id.toLowerCase();
}
return m.grant.subject.id === contact.id && m.grant.subject.type === kind;
});
const alreadyStaged = this._stagedUsers.some((u) => u.id === contact.id && (u._kind ?? 'user') === kind);
if (alreadyMember || alreadyStaged) return;
this._stagedUsers.push(contact);
this._refreshChips();
addBtn.disabled = false;
inputEl.value = '';
dropdown.classList.add('hidden');
dropdown.replaceChildren();
},
/**
* @returns {HTMLElement}
*/
_buildChipsRow() {
const row = document.createElement('div');
row.id = 'smd-chips-row';
row.className = 'smd-chips';
this._renderChipsInto(row);
return row;
},
_refreshChips() {
const row = /** @type {HTMLElement|null} */ (document.getElementById('smd-chips-row'));
if (row) this._renderChipsInto(row);
},
/**
* @param {HTMLElement} container
*/
_renderChipsInto(container) {
container.replaceChildren();
this._stagedUsers.forEach((c) => {
const chip = document.createElement('div');
chip.className = 'smd-chip';
let visual;
if (c._kind === 'group') {
const g = /** @type {GroupSuggestion} */ (c);
visual = createGroupVignette(groupDisplayName(g), 'xs', { icon: groupIconClass(g) });
} else if (c._kind === 'email') {
const e = /** @type {EmailSuggestion} */ (c);
visual = createPendingEmailVignette(e.email, 'xs');
} else {
visual = createUserVignette(c.id, 'xs');
}
const rm = document.createElement('button');
rm.className = 'smd-chip-remove';
rm.innerHTML = '&times;';
rm.title = i18n.t('actions.remove', 'Remove');
const kind = c._kind === 'group' ? 'group' : c._kind === 'email' ? 'email' : 'user';
rm.addEventListener('click', () => {
this._stagedUsers = this._stagedUsers.filter((u) => !(u.id === c.id && (u._kind ?? 'user') === kind));
this._refreshChips();
const addBtn = /** @type {HTMLButtonElement|null} */ (document.querySelector('.smd-add-btn'));
if (addBtn) addBtn.disabled = this._stagedUsers.length === 0;
});
chip.appendChild(visual);
chip.appendChild(rm);
container.appendChild(chip);
});
},
_commitStagedUsers() {
for (const contact of this._stagedUsers) {
// Email-typed stagings carry a transient `_invitedEmail` on the
// resulting MemberEntry. The pre-commit MemberRow rendering
// (`_buildMemberRow`) and the `_applyAll` API-call branch both
// key off that field — they don't try to read a UUID out of
// `subject.id` (which is the email string in this case, not a
// real user UUID until the server resolves it).
const subjectType = contact._kind === 'group' ? 'group' : contact._kind === 'email' ? 'user' : 'user';
/** @type {Grant} */
const placeholderGrant = {
id: '', // not yet persisted
granted_at: '',
granted_by: '',
subject: { type: subjectType, id: contact.id },
role: this._stagedRole,
resource: { type: this._itemType, id: this._item?.id ?? '' }
};
this._localMembers.push({
grant: placeholderGrant,
_grants: [], // no server grants yet — nothing to revoke on remove
role: this._stagedRole,
_op: 'new',
expires_at: this._stagedExpiry,
_displayName: contact._kind === 'group' ? /** @type {GroupSuggestion} */ (contact).name : undefined,
_invitedEmail: contact._kind === 'email' ? /** @type {EmailSuggestion} */ (contact).email : undefined
});
}
this._stagedUsers = [];
this._refreshChips();
this._refreshMemberGroups();
},
/**
* @returns {HTMLElement}
*/
_buildMemberGroups() {
const container = document.createElement('div');
container.id = 'smd-member-groups';
this._renderMemberGroupsInto(container);
return container;
},
_refreshMemberGroups() {
const container = /** @type {HTMLElement|null} */ (document.getElementById('smd-member-groups'));
if (container) this._renderMemberGroupsInto(container);
this._syncApplyBtn();
},
/**
* @param {HTMLElement} container
*/
_renderMemberGroupsInto(container) {
container.replaceChildren();
// Highest-privilege role first ("Can manage" → "Can edit" → "Can view"),
// matching the UX contract and the kebab-menu / role-select dropdown
// order. Renaming the labels from "Manager"/"Editor"/"Viewer" to
// "Can manage"/"Can edit"/"Can view" left this iteration order stale.
const groups = /** @type {ShareRoleEnum[]} */ (['owner', 'editor', 'viewer']);
let memberIndex = 0;
for (const role of groups) {
const visible = this._localMembers.filter((m) => m.role === role && m._op !== 'remove');
if (visible.length === 0) continue;
const group = document.createElement('div');
group.className = 'smd-group';
const header = document.createElement('div');
header.className = 'smd-group-header';
const labelMap = {
owner: i18n.t('share.role.canManage', 'Can manage'),
editor: i18n.t('share.role.canEdit', 'Can edit'),
viewer: i18n.t('share.role.canView', 'Can view')
};
const badge = document.createElement('span');
badge.className = 'smd-group-badge';
badge.textContent = String(visible.length);
header.textContent = labelMap[role];
header.appendChild(badge);
group.appendChild(header);
for (const entry of visible) {
group.appendChild(this._buildMemberRow(entry, memberIndex));
memberIndex++;
}
container.appendChild(group);
}
},
/**
* @param {MemberEntry} entry
* @param {number} _idx (unused — color is now derived deterministically from userId)
* @returns {HTMLElement}
*/
_buildMemberRow(entry, _idx) {
const row = document.createElement('div');
row.className = 'smd-member-row';
// Three rendering paths:
// - Group subject → group vignette
// - Pre-commit email-invite (carries `_invitedEmail`) → pending
// vignette seeded from the email; no UUID exists yet.
// - Regular user subject → user vignette (which itself renders
// the external badge automatically via systemUsers).
const vignette =
entry.grant.subject.type === 'group'
? createGroupVignette(entry._displayName ?? entry.grant.subject.id, 'md', {
icon: groupIconClassByVirtual(entry._isVirtual)
})
: entry._invitedEmail
? createPendingEmailVignette(entry._invitedEmail, 'md')
: createUserVignette(entry.grant.subject.id, 'md');
const roleSelect = document.createElement('select');
roleSelect.className = 'smd-member-role-select';
for (const [val, label] of [
['viewer', i18n.t('share.role.canView', 'Can view')],
['editor', i18n.t('share.role.canEdit', 'Can edit')],
['owner', i18n.t('share.role.canManage', 'Can manage')]
]) {
const opt = document.createElement('option');
opt.value = val;
opt.textContent = label;
if (val === entry.role) opt.selected = true;
roleSelect.appendChild(opt);
}
roleSelect.addEventListener('change', () => {
const newRole = /** @type {ShareRoleEnum} */ (roleSelect.value);
entry.role = newRole;
entry._op = entry._op === 'new' ? 'new' : 'change';
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');
removeBtn.innerHTML = '<i class="fas fa-times"></i>';
removeBtn.addEventListener('click', () => {
entry._op = 'remove';
this._refreshMemberGroups();
});
row.appendChild(vignette);
row.appendChild(roleSelect);
row.appendChild(expiryChip);
row.appendChild(removeBtn);
return row;
},
// ── Expiry chip toggle ─────────────────────────────────────────────────────
/**
* @param {string|null} initialValue - YYYY-MM-DD or null
* @param {(v: string|null) => void} onChange
* @returns {HTMLElement}
*/
_buildExpiryChip(initialValue, onChange) {
return buildExpiryChip(initialValue, onChange);
},
/**
* @param {boolean} initialHasPassword
* @param {(v: string) => void} onChange '' = remove / clear, non-empty = set new password
* @returns {HTMLElement}
*/
_buildPasswordChip(initialHasPassword, onChange) {
return buildPasswordChip(initialHasPassword, onChange);
},
// ── Links section ──────────────────────────────────────────────────────────
/**
* @returns {HTMLElement}
*/
_buildLinksSection() {
const section = document.createElement('div');
section.className = 'smd-section';
const title = document.createElement('div');
title.className = 'smd-section-title';
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);
return section;
},
/**
* 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';
// 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;
});
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;
},
/**
* @param {HTMLElement} container
*/
_renderLinksInto(container) {
container.replaceChildren();
// Existing links
for (const entry of this._localLinks.filter((e) => e._op !== 'remove')) {
container.appendChild(this._buildLinkRow(entry));
}
// Draft (new) links
for (const draft of this._newLinks) {
container.appendChild(this._buildDraftLinkRow(draft));
}
},
_refreshLinks() {
const container = /** @type {HTMLElement|null} */ (document.getElementById('smd-links-list'));
if (container) this._renderLinksInto(container);
this._syncApplyBtn();
},
/**
* @param {LinkEntry} entry
* @returns {HTMLElement}
*/
_buildLinkRow(entry) {
const share = entry.share;
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 name = document.createElement('div');
name.className = 'smd-link-name';
name.textContent = entry._draft?.name || share.item_name || i18n.t('share.sharedLink', 'Shared link');
const copyBtn = document.createElement('button');
copyBtn.className = 'smd-row-action';
copyBtn.title = i18n.t('actions.copy', 'Copy link');
copyBtn.innerHTML = '<i class="fas fa-copy"></i>';
copyBtn.addEventListener('click', () => fileSharing.copyLinkToClipboard(share.url));
const pwChip = this._buildPasswordChip(currentHasPassword, (v) => {
ensureDraft().password = v;
});
const expChip = this._buildExpiryChip(currentExpiry, (v) => {
ensureDraft().expires_at = v;
});
const delBtn = document.createElement('button');
delBtn.className = 'smd-row-action';
delBtn.title = i18n.t('actions.delete', 'Delete');
delBtn.innerHTML = '<i class="fas fa-times"></i>';
delBtn.addEventListener('click', () => {
entry._op = 'remove';
this._refreshLinks();
});
row.appendChild(name);
row.appendChild(copyBtn);
row.appendChild(pwChip);
row.appendChild(expChip);
row.appendChild(delBtn);
return row;
},
/**
* @param {DraftLink} draft
* @returns {HTMLElement}
*/
_buildDraftLinkRow(draft) {
const row = document.createElement('div');
row.className = 'smd-link-row';
const name = document.createElement('div');
name.className = 'smd-link-name';
name.textContent = draft.name || i18n.t('share.newLink', 'New link');
const pending = document.createElement('span');
pending.className = 'smd-link-tag';
pending.textContent = i18n.t('share.pending', 'Pending');
const pwChip = this._buildPasswordChip(!!draft.password, (v) => {
draft.password = v || null;
});
const expChip = this._buildExpiryChip(draft.expires_at, (v) => {
draft.expires_at = v;
});
const delBtn = document.createElement('button');
delBtn.className = 'smd-row-action';
delBtn.title = i18n.t('actions.remove', 'Remove');
delBtn.innerHTML = '<i class="fas fa-times"></i>';
delBtn.addEventListener('click', () => {
this._newLinks = this._newLinks.filter((d) => d !== draft);
this._refreshLinks();
});
row.appendChild(name);
row.appendChild(pending);
row.appendChild(pwChip);
row.appendChild(expChip);
row.appendChild(delBtn);
return row;
},
// ── Apply ──────────────────────────────────────────────────────────────────
/**
* Commit all pending local operations to the server, then close.
* @returns {Promise<void>}
*/
async _applyAll() {
if (!this._item) return;
// Disable the Apply button while working
if (Modal.confirmBtn) Modal.confirmBtn.disabled = true;
const item = this._item;
const itemType = this._itemType;
// Accumulate notification outcomes across all create-grant calls
// in this apply round so the post-apply summary aggregates ("3
// recipients notified, 1 already notified recently") rather than
// showing one toast per granted member.
/** @type {Array<{kind: string, detail?: string, last_sent_at?: string, retry_after_secs?: number, reason?: string}>} */
const notifyOutcomes = [];
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) {
if (g.id) await grants.revokeGrant(g.id);
}
} else if (m._op === 'change' && m.grant.id) {
await grants.updateRole({
subject: { type: m.grant.subject.type, id: m.grant.subject.id },
resource: { type: itemType, id: item.id },
role: m.role,
expires_at: expiresIso
});
} else if (m._op === 'new') {
// Email-invite path: the staged MemberEntry carries
// `_invitedEmail`; the server resolves it to (or
// creates) an external user and returns the actual
// user_id in the grant DTO. Until `fetchOutgoingGrants`
// refreshes below, the row keeps the pending vignette.
const subject = m._invitedEmail ? { type: 'email', email: m._invitedEmail } : { type: m.grant.subject.type, id: m.grant.subject.id };
const result = await grants.createGrant({
subject,
resource: { type: itemType, id: item.id },
role: m.role,
expires_at: expiresIso
});
// PR N1: collect per-recipient notification outcomes
// so we can show one aggregated summary after the loop.
if (result?.notification?.outcomes) {
notifyOutcomes.push(...result.notification.outcomes);
}
}
}
// ── Links ──────────────────────────────────────────────────────────
for (const e of this._localLinks) {
if (e._op === 'remove') {
await fileSharing.removeSharedLink(e.share.id);
} else if (e._op === 'edit' && e._draft) {
const expiresTs = e._draft.expires_at ? Math.floor(new Date(e._draft.expires_at).getTime() / 1000) : null;
await fileSharing.updateSharedLink(e.share.id, {
password: e._draft.password,
expires_at: expiresTs
});
}
}
for (const draft of this._newLinks) {
await fileSharing.createSharedLink(
item.id,
itemType,
/** @type {import('../core/types.js').CreateShare} */ ({
item_id: item.id,
item_name: item.name ?? null,
item_type: itemType,
password: draft.password,
// Pass as ms timestamp so fileSharing's new Date(expires_at) works correctly
expires_at: draft.expires_at ? new Date(draft.expires_at).getTime() : null,
permissions: { read: true, write: false, reshare: false }
})
);
}
// ── Refresh badge cache ────────────────────────────────────────────
await grants.fetchOutgoingGrants();
const hasAnyShare =
this._localMembers.some((m) => m._op !== 'remove') || this._localLinks.some((e) => e._op !== 'remove') || this._newLinks.length > 0;
ui.setSharedVisualState(item.id, itemType, hasAnyShare);
Modal.close(true);
this._onApplied?.();
// PR N1: surface share-notification outcomes. The granter
// needs to know whether the recipient actually got an email
// (or was silently coalesced / rate-limited / opted out).
// Without a project-wide toast component the cheapest
// honest signal is a console log + a one-shot alert() for
// the non-success states. A proper toast surface lands in
// a small follow-up; the backend data is correct, the UI
// is just brief.
_surfaceNotifySummary(notifyOutcomes);
} catch (err) {
console.error('shareModal._applyAll error:', err);
if (Modal.confirmBtn) Modal.confirmBtn.disabled = false;
}
}
};
/**
* Show a one-shot aggregated summary of share-notification outcomes
* after a batch of create-grant calls. v1 surface is minimal — logs
* everything to the console for traceability and pops a single alert()
* only when at least one recipient was coalesced, rate-limited, or
* landed on the not-applicable arm (i.e. the granter SHOULD know the
* email didn't go). The all-Sent happy path stays silent because the
* modal-close already implies success.
*
* A proper toast component is deferred; this function is the seam to
* upgrade later — replace the alert() body, keep the call site.
*
* @param {Array<{kind: string, detail?: string, last_sent_at?: string, retry_after_secs?: number, reason?: string}>} outcomes
*/
function _surfaceNotifySummary(outcomes) {
if (!outcomes || outcomes.length === 0) return;
// Always log — useful in dev tools regardless of the alert path.
console.log('[share] notification outcomes:', outcomes);
const sent = outcomes.filter((o) => o.kind === 'sent').length;
const coalesced = outcomes.filter((o) => o.kind === 'coalesced').length;
const rateLimited = outcomes.filter((o) => o.kind === 'rate_limited').length;
const notApplicable = outcomes.filter((o) => o.kind === 'not_applicable');
// Happy path — all sent. Stay silent; the closed modal is the toast.
if (coalesced === 0 && rateLimited === 0 && notApplicable.length === 0) return;
/** @type {string[]} */
const lines = [];
if (sent > 0) {
lines.push(`${sent} recipient(s) notified by email.`);
}
if (coalesced > 0) {
lines.push(`${coalesced} recipient(s) already notified recently — they'll see the share at next login.`);
}
if (rateLimited > 0) {
lines.push(`${rateLimited} recipient(s) hit the notification rate limit — try again later.`);
}
if (notApplicable.length > 0) {
const reasons = notApplicable
.map((o) => o.reason)
.filter((r, i, arr) => r && arr.indexOf(r) === i)
.join(', ');
lines.push(`${notApplicable.length} recipient(s) skipped (${reasons || 'unknown'}).`);
}
// eslint-disable-next-line no-alert -- minimal v1 surface; toast component lands as follow-up
alert(lines.join('\n'));
}
export { shareModal };