// Invite-member modal. Mirrors workspace-rename-modal.js's structure // (overlay + listeners + close + esc/click-outside/enter) with two key // differences: // // 1. On success calls an onSuccess(result) callback instead of // window.location.reload(). The parent view (workspace-members.js) // re-fetches and re-renders just the pending-invites section - no // full-page flash for a single row addition. // // 2. Server errors map to translated strings via a mapError callback // passed by the parent (mapMutationError lives in workspace-members.js). // That keeps a single error mapper for ALL slice 2B mutations rather // than scattering modal-specific copies. Inline display below the form // (not toast) so user can correct + resubmit without closing. import { api } from '../api.js'; import { t } from '../i18n.js'; const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; // open the modal. // workspace: { id, name } - id used for the API call, name shown in title // opts.onSuccess: (result) => void - fires on 200; result is the server // response body { id, email, role, expires_at } // opts.mapError: (err) => string - translates server error to display text export function openInviteMemberModal(workspace, opts = {}) { const { onSuccess, mapError } = opts; const overlay = document.createElement('div'); overlay.className = 'modal-overlay'; overlay.innerHTML = ` `; document.body.appendChild(overlay); const emailInput = overlay.querySelector('#inviteEmail'); const roleSelect = overlay.querySelector('#inviteRole'); const errorEl = overlay.querySelector('#inviteModalError'); const sendBtn = overlay.querySelector('#inviteSendBtn'); emailInput.focus(); function close() { overlay.remove(); document.removeEventListener('keydown', onKey); } function onKey(e) { if (e.key === 'Escape') close(); else if (e.key === 'Enter' && (e.target === emailInput || e.target === roleSelect)) send(); } document.addEventListener('keydown', onKey); overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); }); overlay.querySelectorAll('[data-invite-close]').forEach(b => b.addEventListener('click', close)); async function send() { errorEl.style.display = 'none'; const email = emailInput.value.trim().toLowerCase(); const role = roleSelect.value; // Client-side email validation - server validates too, but this avoids a // round-trip and gives immediate feedback on obvious typos. if (!email || !EMAIL_RE.test(email)) { showError(t('members.error.invalid_email')); emailInput.focus(); return; } sendBtn.disabled = true; sendBtn.textContent = t('members.modal.sending'); try { const result = await api.inviteWorkspaceMember(workspace.id, { email, role }); close(); // Defensive: undefined onSuccess is a no-op; a thrown onSuccess (parent // bug) is logged but not propagated so the modal-close still succeeded // from the user's perspective. if (typeof onSuccess === 'function') { try { onSuccess(result); } catch (e) { console.error('invite modal onSuccess threw:', e); } } } catch (err) { sendBtn.disabled = false; sendBtn.textContent = t('members.modal.send'); // Map via parent-supplied helper. Fallback to raw message if no mapper // was provided (shouldn't happen in normal use, defensive only). const msg = (typeof mapError === 'function') ? mapError(err) : (err?.message || t('members.error.mutation_generic', { error: '' })); showError(msg); } } function showError(msg) { errorEl.textContent = msg; errorEl.style.display = 'block'; } sendBtn.addEventListener('click', send); } function esc(s) { return String(s ?? '').replace(/[&<>"']/g, c => ({ '&':'&','<':'<','>':'>','"':'"',"'":''' }[c])); }