Files
Oxicloud/static/js/components/shareModal.js
T
Edouard Vanbelle f168c4578f feat(roles): prepare migration ReBAC to roles
prepare migration of permission to roles
    this simplify drastically database (permission are now simply defined in code)
    and will permit reuse of the same ReBAC engine to define owners of drives

    mapping:

    ```
        Role::Viewer => &[Permission::Read],
        Role::Commenter => &[Permission::Read, Permission::Comment],
        Role::Contributor => &[Permission::Read, Permission::Create],
        Role::Editor => &[
            Permission::Read,
            Permission::Comment,
            Permission::Create,
            Permission::Update,
        ],
        Role::Owner => &[
            Permission::Read,
            Permission::Comment,
            Permission::Create,
            Permission::Update,
            Permission::Share,
            Permission::Delete,
            Permission::Manage,
        ],
    ```
2026-06-18 02:47:06 +02:00

1169 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);
}
/**
* Permissions that belong to each role (mirrors `Role::expand()` in
* `src/application/dtos/grant_dto.rs`). The share modal only renders
* Viewer/Editor/Owner as picker buttons today; `commenter` and
* `contributor` are kept here for fidelity with the server-side enum so
* a future UI exposure doesn't need a mirror-table update. The `manage`
* permission in the owner bundle is reserved for Drive- and Group-level
* admin actions (no-op on file/folder resources).
*/
const ROLE_PERMISSIONS = {
viewer: ['read'],
commenter: ['read', 'comment'],
contributor: ['read', 'create'],
editor: ['read', 'comment', 'create', 'update'],
owner: ['read', 'comment', 'create', 'update', 'share', 'delete', 'manage']
};
/**
* 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 [];
}
}
/**
* Derive the highest role a set of grants represents for one subject.
* @param {Grant[]} subjectGrants
* @returns {ShareRoleEnum}
*/
function _roleFromGrants(subjectGrants) {
const perms = new Set(subjectGrants.map((g) => g.permission));
if (perms.has('delete') || perms.has('share')) return 'owner';
if (perms.has('create') || perms.has('update')) 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 },
permission: /** @type {import('../core/types.js').PermissionTypeEnum} */ (ROLE_PERMISSIONS[this._stagedRole][0]),
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 };