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:
@@ -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).
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -338,6 +338,10 @@ const OxiIcons = {
|
||||
576,
|
||||
'm 360.55,24 v 72 h 64 c 79.5,0 144,64.5 144,144 0,93.4 -82.8,134.8 -100.6,142.6 -2.2,1 -4.6,1.4 -7.1,1.4 h -2.5 c -9.8,0 -17.8,-8 -17.8,-17.8 0,-8.3 5.9,-15.5 12.8,-20.3 8.9,-6.2 19.2,-18.2 19.2,-40.5 0,-45 -36.5,-81.5 -81.5,-81.5 h -30.5 v 72 c 0,9.7 -5.8,18.5 -14.8,22.2 -9,3.7 -19.3,1.7 -26.2,-5.2 l -136,-136 c -9.4,-9.4 -9.4,-24.6 0,-33.9 l 136,-136 c 6.9,-6.9 17.2,-8.9 26.2,-5.2 9,3.7 14.8,12.5 14.8,22.2 z M 112.5,96 c -44.2,0 -80,35.8 -80,80 v 256 c 0,44.2 35.8,80 80,80 h 256 c 44.2,0 80,-35.8 80,-80 v -32 c 0,-17.7 -14.3,-32 -32,-32 -17.7,0 -32,14.3 -32,32 v 32 c 0,8.8 -7.2,16 -16,16 h -256 c -8.8,0 -16,-7.2 -16,-16 V 176 c 0,-8.8 7.2,-16 16,-16 h 16 c 17.7,0 32,-14.3 32,-32 0,-17.7 -14.3,-32 -32,-32 z'
|
||||
],
|
||||
'paper-plane': [
|
||||
576,
|
||||
'M290.5 287.7L491.4 86.9 359 456.3 290.5 287.7zM457.4 53L256.6 253.8 88 185.3 457.4 53zM38.1 216.8l205.8 83.6 83.6 205.8c5.3 13.1 18.1 21.7 32.3 21.7 14.7 0 27.8-9.2 32.8-23.1L570.6 8c3.5-9.8 1-20.6-6.3-28s-18.2-9.8-28-6.3L39.4 151.7c-13.9 5-23.1 18.1-23.1 32.8 0 14.2 8.6 27 21.7 32.3z'
|
||||
],
|
||||
pause: [
|
||||
384,
|
||||
'M48 32C21.5 32 0 53.5 0 80L0 432c0 26.5 21.5 48 48 48l64 0c26.5 0 48-21.5 48-48l0-352c0-26.5-21.5-48-48-48L48 32zm224 0c-26.5 0-48 21.5-48 48l0 352c0 26.5 21.5 48 48 48l64 0c26.5 0 48-21.5 48-48l0-352c0-26.5-21.5-48-48-48l-64 0z'
|
||||
|
||||
@@ -166,6 +166,7 @@
|
||||
* @property {string} [family_name] Last/family name; set at OIDC JIT or via PATCH /api/auth/me/profile (PR 24)
|
||||
* @property {string} [email_verified_at] ISO 8601 timestamp of the first proof-of-email-control (PR 23). Omitted when unverified.
|
||||
* @property {string} [preferred_locale] User-chosen locale code (e.g. `"fr"`, `"zh-TW"`); omitted when unset. Round-trips via PATCH /api/auth/me/profile.
|
||||
* @property {boolean} notify_on_share Whether the user wants share-notification emails ("Alice shared X with you"). Default TRUE. Toggled via the profile checkbox; round-trips via PATCH /api/auth/me/profile.
|
||||
*/
|
||||
|
||||
/**
|
||||
@@ -365,6 +366,7 @@
|
||||
* @property {string} granted_at - ISO-8601
|
||||
* @property {string|null} [expires_at] - ISO-8601 or absent.
|
||||
* @property {boolean} has_password - True when a token subject has a password set.
|
||||
* @property {boolean} [is_external] - True when a user subject is a magic-link-only external user (PR N2). Drives the My Shares menu label ("Resend invitation email" vs "Notify by email"). Always false for token and group subjects.
|
||||
*/
|
||||
|
||||
/**
|
||||
|
||||
@@ -166,8 +166,26 @@ const grants = {
|
||||
* Create a new grant.
|
||||
* Body mirrors `CreateGrantDto`: `{ subject, resource, role }` OR `{ subject, resource, permissions }`.
|
||||
*
|
||||
* Response shape (PR N1 — `CreateGrantResponseDto`):
|
||||
*
|
||||
* ```json
|
||||
* {
|
||||
* "grants": [ {Grant}, … ],
|
||||
* "notification": {
|
||||
* "total_recipients": 1,
|
||||
* "outcomes": [{ "kind": "sent", "detail": "plain_notification" }]
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* `notification.outcomes` is empty for token subjects; size 1 for
|
||||
* user subjects; size N for group subjects (one entry per resolved
|
||||
* member). Callers that just need the grant rows can `.grants`;
|
||||
* callers that want to surface "did Carol get my email?" UX read
|
||||
* `.notification.outcomes[]`.
|
||||
*
|
||||
* @param {Object} dto - CreateGrantDto shape
|
||||
* @returns {Promise<Grant[]>}
|
||||
* @returns {Promise<{ grants: Grant[], notification: { total_recipients: number, outcomes: Array<{kind: string, detail?: string, last_sent_at?: string, retry_after_secs?: number, reason?: string}> } }>}
|
||||
*/
|
||||
async createGrant(dto) {
|
||||
const response = await fetch('/api/grants', {
|
||||
|
||||
@@ -645,6 +645,14 @@ function _renderProfileEdit(user) {
|
||||
}
|
||||
givenInput.value = user.given_name || '';
|
||||
familyInput.value = user.family_name || '';
|
||||
|
||||
const notifyInput = /** @type {HTMLInputElement | null} */ (document.getElementById('profile-edit-notify-on-share'));
|
||||
if (notifyInput) {
|
||||
// notify_on_share is a boolean on the server; default TRUE for
|
||||
// pre-existing rows via the column default, so the checkbox is
|
||||
// ticked unless the user has explicitly opted out.
|
||||
notifyInput.checked = user.notify_on_share !== false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -665,7 +673,7 @@ async function submitProfile(e) {
|
||||
const givenInput = /** @type {HTMLInputElement} */ (document.getElementById('profile-edit-given-name'));
|
||||
const familyInput = /** @type {HTMLInputElement} */ (document.getElementById('profile-edit-family-name'));
|
||||
|
||||
/** @type {{ username?: string, given_name?: string, family_name?: string }} */
|
||||
/** @type {{ username?: string, given_name?: string, family_name?: string, notify_on_share?: boolean }} */
|
||||
const body = {};
|
||||
if (!usernameInput.disabled && usernameInput.value.trim()) {
|
||||
body.username = usernameInput.value.trim();
|
||||
@@ -675,6 +683,15 @@ async function submitProfile(e) {
|
||||
const family = familyInput.value.trim();
|
||||
if (family) body.family_name = family;
|
||||
|
||||
// Always send the share-notification preference. The backend
|
||||
// compares against the current value and skips the write if
|
||||
// unchanged, so this is idempotent — sending it on every save
|
||||
// simplifies the frontend rather than tracking a dirty bit.
|
||||
const notifyInput = /** @type {HTMLInputElement | null} */ (document.getElementById('profile-edit-notify-on-share'));
|
||||
if (notifyInput) {
|
||||
body.notify_on_share = notifyInput.checked;
|
||||
}
|
||||
|
||||
if (Object.keys(body).length === 0) {
|
||||
statusEl.innerHTML = `<div class="alert alert-info"><i class="fas fa-info-circle"></i> ${escapeHtml(i18n.t('profile.profile_no_changes'))}</div>`;
|
||||
return false;
|
||||
|
||||
Reference in New Issue
Block a user