feat(notify): add notif to internal users when granted

- add coalesced protection to avoid mail bombing if an invited goes many grant in a short period
    - add resentd method in share menu item (work for both internal and external users)
    - user can disable email notification via his properties
    - add env variable from admin to disable notifications
This commit is contained in:
Edouard Vanbelle
2026-06-05 09:46:51 +02:00
parent b8e8fcd7dc
commit 8cc21f17c5
45 changed files with 1906 additions and 81 deletions
+81
View File
@@ -9,6 +9,7 @@
* 'sharedWith' — lane = user | 'links:public' | 'links:password'; row identity = resource
*/
import { getCsrfHeaders } from '../core/csrf.js';
import { formatExpiryChip } from '../core/formatters.js';
import { i18n } from '../core/i18n.js';
import { fileSharing } from '../features/sharing/fileSharing.js';
@@ -432,6 +433,24 @@ class MySharesList {
const initialExpiry = grant.expires_at ? String(grant.expires_at).slice(0, 10) : null;
if (grant.subject_type === 'user' || grant.subject_type === 'group') {
// PR N2 — "Resend invitation email" / "Notify by email" /
// "Notify group members". First item in the menu; only
// present for user and group subjects (token shares have
// no email channel; the server returns 409 anyway).
const notifyLabel =
grant.subject_type === 'group'
? i18n.t('myshares.notifyGroupMembers', 'Notify group members')
: grant.is_external
? i18n.t('myshares.resendInvitation', 'Resend invitation email')
: i18n.t('myshares.notifyByEmail', 'Notify by email');
menu.appendChild(
this._menuItem('fas fa-paper-plane', notifyLabel, false, async () => {
menu.remove();
await this._notifyRecipient(grant);
})
);
menu.appendChild(this._menuSeparator());
for (const role of /** @type {('admin'|'editor'|'viewer')[]} */ (['admin', 'editor', 'viewer'])) {
const isCurrent = grant.role === role;
const mi = this._menuItem(isCurrent ? 'fas fa-check' : '', roleLabel(role), false, async () => {
@@ -554,6 +573,68 @@ class MySharesList {
return row;
}
/**
* PR N2 — manual share-notification resend. Calls
* `POST /api/grants/{grant_id}/notify` and surfaces the aggregated
* outcome to the granter. The endpoint returns:
* - 204 No Content — all recipients sent
* - 200 + NotifyOutcomeSetDto — mixed outcomes (coalesced /
* not-applicable / partial sent)
* - 429 Too Many Requests — per-recipient rate limit hit on every
* recipient
* - 404 Not Found — caller is not the granter, or grant
* doesn't exist (anti-enumeration; the audit log carries the
* truth)
* - 409 Conflict — token subject (UI shouldn't reach this)
*
* @param {OutgoingResourceGrant} grant
*/
async _notifyRecipient(grant) {
try {
const resp = await fetch(`/api/grants/${encodeURIComponent(grant.grant_id)}/notify`, {
method: 'POST',
credentials: 'same-origin',
headers: { ...getCsrfHeaders() }
});
if (resp.status === 204) {
// All sent — silent success.
console.log('[myshares] notify: all recipients sent', grant.grant_id);
return;
}
if (resp.status === 429) {
// eslint-disable-next-line no-alert -- minimal v1 surface
alert(i18n.t('myshares.notifyRateLimited', 'Too many notifications for this recipient — try again later.'));
return;
}
if (resp.ok) {
/** @type {{ total_recipients: number, outcomes: Array<{kind: string, detail?: string, reason?: string}> }} */
const body = await resp.json();
console.log('[myshares] notify outcomes:', body);
const sent = body.outcomes.filter((o) => o.kind === 'sent').length;
const coalesced = body.outcomes.filter((o) => o.kind === 'coalesced').length;
const notApplicable = body.outcomes.filter((o) => o.kind === 'not_applicable').length;
/** @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 (notApplicable > 0) lines.push(`${notApplicable} recipient(s) skipped (opted out, no email, or operator-disabled).`);
if (lines.length > 0) {
// eslint-disable-next-line no-alert -- minimal v1 surface
alert(lines.join('\n'));
}
return;
}
// 404 / 409 / unexpected
console.error('[myshares] notify failed:', resp.status);
// eslint-disable-next-line no-alert -- minimal v1 surface
alert(i18n.t('myshares.notifyFailed', 'Could not send notification.'));
} catch (err) {
console.error('[myshares] notify error:', err);
// eslint-disable-next-line no-alert -- minimal v1 surface
alert(i18n.t('myshares.notifyFailed', 'Could not send notification.'));
}
}
/**
* Non-closing password row embedded in the link context menu.
* Saves immediately on confirm (blur / Enter).
+73 -1
View File
@@ -1004,6 +1004,13 @@ const shareModal = {
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) {
@@ -1028,12 +1035,17 @@ const shareModal = {
// 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 };
await grants.createGrant({
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);
}
}
}
@@ -1076,6 +1088,16 @@ const shareModal = {
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;
@@ -1083,4 +1105,54 @@ const shareModal = {
}
};
/**
* 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 };