+ Si le bouton ne fonctionne pas, copiez ce lien dans votre navigateur :
+ ${esc(inscriptionUrl)}
+
+
+
+
+
+`;
+
+ return { subject, html, inscriptionUrl };
+}
diff --git a/server/src/index.js b/server/src/index.js
index 047a1df..143e861 100644
--- a/server/src/index.js
+++ b/server/src/index.js
@@ -39,6 +39,11 @@ app.use(
},
},
xFrameOptions: { action: 'deny' },
+ // Site images (logo, uploads) are public and referenced from outside
+ // the app's origin — e.g. the logo embedded in invitation emails is
+ // loaded by webmail clients. The default same-origin policy would
+ // block those loads.
+ crossOriginResourcePolicy: { policy: 'cross-origin' },
})
);
app.use(compression());
diff --git a/server/src/routes/admin.js b/server/src/routes/admin.js
index ef98982..944d82c 100644
--- a/server/src/routes/admin.js
+++ b/server/src/routes/admin.js
@@ -18,6 +18,7 @@ import { imageUpload, saveImage, deleteImage } from '../uploads.js';
import { ensureMemberAccess } from '../memberAccess.js';
import { createStaffUser, resetStaffAccess } from '../userAccess.js';
import { AccessError } from '../errors.js';
+import { buildInvitationEmail } from '../emailTemplate.js';
export const adminRouter = Router();
@@ -227,6 +228,22 @@ adminRouter.post('/rencontres/:id/image', validate(idParam, 'params'), (req, res
});
});
+// Ready-to-send HTML invitation email for a rencontre (admin + moderator).
+// The registration link opens the public site with the inscription modal
+// pre-opened (/?inscription=). URLs are absolute, built from the
+// request's host so they match however the site is reached (direct port
+// or reverse proxy).
+adminRouter.get('/rencontres/:id/email', validate(idParam, 'params'), async (req, res, next) => {
+ try {
+ const result = await query(`${RENC_SQL} WHERE r.id = $1 GROUP BY r.id`, [req.params.id]);
+ if (result.rowCount === 0) return res.status(404).json({ error: 'Rencontre introuvable' });
+ const baseUrl = `${req.protocol}://${req.get('host')}`;
+ res.json(buildInvitationEmail({ rencontre: result.rows[0], baseUrl }));
+ } catch (err) {
+ next(err);
+ }
+});
+
adminRouter.get('/rencontres/:id/inscriptions', validate(idParam, 'params'), async (req, res, next) => {
try {
const result = await query(`${INSCR_SQL} WHERE i.rencontre_id = $1 ORDER BY i.nom`, [req.params.id]);
diff --git a/web/src/components/admin/AdminTabs.jsx b/web/src/components/admin/AdminTabs.jsx
index a6acc9f..baafb1c 100644
--- a/web/src/components/admin/AdminTabs.jsx
+++ b/web/src/components/admin/AdminTabs.jsx
@@ -316,6 +316,101 @@ function escHtml(v) {
return String(v ?? '').replace(/&/g, '&').replace(//g, '>');
}
+// Preview + export of the ready-to-send HTML invitation email generated
+// server-side for one rencontre. "Copier l'email" puts the rendered HTML
+// on the clipboard (rich text where supported, so pasting into a mail
+// client keeps the design); the .html download covers the rest.
+function EmailModal({ renc, onClose }) {
+ const [data, setData] = useState(null);
+ const [error, setError] = useState('');
+ const [copied, setCopied] = useState('');
+
+ useEffect(() => {
+ api.get(`/api/admin/rencontres/${renc.id}/email`).then(setData).catch((e) => setError(e.message));
+ }, [renc.id]);
+
+ const flash = (what) => {
+ setCopied(what);
+ setTimeout(() => setCopied(''), 1800);
+ };
+
+ const copyEmail = async () => {
+ setError('');
+ try {
+ if (navigator.clipboard?.write && window.ClipboardItem) {
+ await navigator.clipboard.write([
+ new window.ClipboardItem({
+ 'text/html': new Blob([data.html], { type: 'text/html' }),
+ 'text/plain': new Blob([`${data.subject}\n${data.inscriptionUrl}`], { type: 'text/plain' }),
+ }),
+ ]);
+ } else {
+ await navigator.clipboard.writeText(data.html);
+ }
+ flash('email');
+ } catch {
+ setError('Copie impossible dans ce navigateur — utilisez le téléchargement.');
+ }
+ };
+
+ const copyText = async (text, what) => {
+ setError('');
+ try {
+ await navigator.clipboard.writeText(text);
+ flash(what);
+ } catch {
+ setError('Copie impossible dans ce navigateur.');
+ }
+ };
+
+ const download = () => {
+ const blob = new Blob([data.html], { type: 'text/html' });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ const slug = renc.titre.normalize('NFD').replace(/[̀-ͯ]/g, '').replace(/[^a-zA-Z0-9]+/g, '-').toLowerCase();
+ a.href = url;
+ a.download = `invitation-${slug}.html`;
+ document.body.appendChild(a);
+ a.click();
+ document.body.removeChild(a);
+ setTimeout(() => URL.revokeObjectURL(url), 2000);
+ };
+
+ return (
+
+ {!data && !error &&
Génération de l'email…
}
+ {error &&
{error}
}
+ {data && (
+ <>
+
+
+
+
+
+
+
+ Objet : {data.subject}
+
+ Collez l'email directement dans votre logiciel de messagerie (le bouton « Copier l'email »
+ conserve la mise en forme), ou envoyez le fichier .html avec votre outil d'emailing.
+