diff --git a/components/email/calendar-invitation-banner.tsx b/components/email/calendar-invitation-banner.tsx index 007880c61..75dd343bb 100644 --- a/components/email/calendar-invitation-banner.tsx +++ b/components/email/calendar-invitation-banner.tsx @@ -1,6 +1,7 @@ 'use client'; import { useState, useEffect, useCallback, useRef } from 'react'; +import { answerInvitation } from '@/lib/calendar-rsvp'; import { createPortal } from 'react-dom'; import { ArrowRight, @@ -342,26 +343,6 @@ function buildInvitationChangeItems( return changes; } -function buildParticipantsForRsvp( - event: Partial, - participantId: string, - status: 'accepted' | 'tentative' | 'declined', -): Record[string]> | null { - if (!event.participants) { - return null; - } - - return Object.fromEntries( - Object.entries(event.participants).map(([id, participant]) => [ - id, - { - ...participant, - participationStatus: id === participantId ? status : participant.participationStatus, - }, - ]), - ); -} - function getMethodIconTone(method: InvitationMethod, actorStatus?: string | null): string { switch (method) { case 'cancel': @@ -733,78 +714,30 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp setActionNotice(null); setActionError(null); setIsProcessing(true); - const replyToForRsvp = parsedEvent.replyTo - || (parsedEvent.organizerCalendarAddress ? { imip: parsedEvent.organizerCalendarAddress } : null); - - // The iMIP REPLY to the organizer is sent by the server: rsvpEvent / - // updateEvent below pass sendSchedulingMessages=true to CalendarEvent/set - // and Stalwart queues the iTIP REPLY itself. A manual client-side reply - // here produced duplicate emails. try { - // A copy in a shared or group account may not be loaded yet: look it up - // there before adding the invitation again. - const eventForRsvp = existingEvent - ? await resolveExistingEventForRsvp() - : sharedMailAccount && parsedEvent.uid ? await loadSharedCopy(parsedEvent.uid) : null; - const existingEventParticipant = eventForRsvp - ? findParticipantByEmails(eventForRsvp, actingEmails) - : null; - const canFallbackToParsedParticipant = Boolean( - existingEvent - && myParticipant - && (!eventForRsvp?.participants || Object.keys(eventForRsvp.participants).length === 0) - ); - if (eventForRsvp && existingEventParticipant) { - // false: a plugin stopped it and said why; keep the old answer. - if (!(await (answerOptions ? rsvpEvent(client, eventForRsvp.id, existingEventParticipant.id, status, replyToForRsvp, undefined, answerOptions) : rsvpEvent(client, eventForRsvp.id, existingEventParticipant.id, status, replyToForRsvp)))) return; + const outcome = await answerInvitation({ + client, + store: { rsvpEvent, updateEvent, importEvents, events: () => useCalendarStore.getState().events }, + parsedEvent, + existingEvent, + myParticipant, + actingEmails, + calendarId: calId, + sharedMailAccount, + answerOptions, + resolveExisting: () => (existingEvent + ? resolveExistingEventForRsvp() + : parsedEvent.uid ? loadSharedCopy(parsedEvent.uid) : Promise.resolve(null)), + }, status); + // 'stopped': a plugin stopped it and said why; keep the old answer. + if (outcome === 'answered') { setRsvpStatus(status); setActionNotice(t('rsvp_sent')); setState('parsed'); - } else if (eventForRsvp && canFallbackToParsedParticipant && myParticipant) { - const repairedParticipants = buildParticipantsForRsvp(parsedEvent, myParticipant.id, status); - - if (!repairedParticipants) { - setActionError(t('action_failed')); - } else { - const updated = await updateEvent(client, eventForRsvp.id, { - participants: repairedParticipants, - // Stalwart routes the iTIP REPLY via the stored ORGANIZER - // (organizerCalendarAddress; the RFC 8984 replyTo is retired). - // Only repair a missing organizer - attendees may not modify it. - ...(replyToForRsvp?.imip && !eventForRsvp.organizerCalendarAddress - ? { organizerCalendarAddress: replyToForRsvp.imip } - : {}), - }, true); - if (!updated) return; - setRsvpStatus(status); - setActionNotice(t('rsvp_sent')); - setState('parsed'); - } - } else if (eventForRsvp) { - setActionError(t('action_failed')); - } else if (calId) { - // A refusal (Stalwart 1.0's participant limit, say) lands in the - // catch below and is said as it is. - const imported = await importEvents(client, [parsedEvent], calId, { throwIfNoneImported: true }); - if (imported === null) return; - if (imported > 0) { - const newEvent = useCalendarStore.getState().events.find( - (e) => e.uid === parsedEvent.uid && (sharedMailAccount ? e.accountId === sharedMailAccount : !e.isShared) - ); - const participant = myParticipant - || (newEvent ? findParticipantByEmails(newEvent, actingEmails) : null); - if (newEvent && participant) { - if (!(await (answerOptions ? rsvpEvent(client, newEvent.id, participant.id, status, replyToForRsvp, undefined, answerOptions) : rsvpEvent(client, newEvent.id, participant.id, status, replyToForRsvp)))) return; - setRsvpStatus(status); - setActionNotice(t('rsvp_sent')); - } else { - setActionNotice(t('added')); - } - setState('parsed'); - } else { - setActionError(t('action_failed')); - } - } else { + } else if (outcome === 'added') { + setActionNotice(t('added')); + setState('parsed'); + } else if (outcome === 'failed') { setActionError(t('action_failed')); } } catch (err) { diff --git a/components/settings/__tests__/composing-settings-locks.test.tsx b/components/settings/__tests__/composing-settings-locks.test.tsx new file mode 100644 index 000000000..9370e46f8 --- /dev/null +++ b/components/settings/__tests__/composing-settings-locks.test.tsx @@ -0,0 +1,77 @@ +import { render, screen, within } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { ComposingSettings } from '../composing-settings'; +import { useSettingsStore } from '@/stores/settings-store'; +import { useAuthStore } from '@/stores/auth-store'; +import { usePolicyStore } from '@/stores/policy-store'; + +// The admin panel offers locks for these three rows (restriction-keys.ts); +// the page has to show them, or the user changes a value the next policy +// load puts back. + +vi.mock('next-intl', () => ({ + useTranslations: () => (key: string) => key, +})); + +vi.mock('../settings-section', async (importOriginal) => ({ + ...(await importOriginal()), + SettingsSection: ({ children }: { children: React.ReactNode }) =>
{children}
, +})); + +const ROWS = { + plainTextMode: 'plain_text_mode.label', + autoSelectReplyIdentity: 'auto_select_reply_identity.label', + replyIdentityMatch: 'reply_identity_match.label', +} as const; + +function setPolicy(locked: string[], hidden: string[] = []) { + usePolicyStore.setState({ + ...usePolicyStore.getState(), + policy: {}, + isSettingLocked: (key: string) => locked.includes(key), + isSettingHidden: (key: string) => hidden.includes(key), + isFeatureEnabled: (gate: string) => gate === 'customFromEnabled', + } as never); +} + +/** The SettingItem row of a label: the element that holds the control. */ +function row(labelKey: string): HTMLElement { + return screen.getByText(labelKey).closest('[data-search-label]') as HTMLElement; +} + +describe('ComposingSettings admin locks', () => { + beforeEach(() => { + useSettingsStore.getState().resetToDefaults(); + // The identity match row shows only with auto-select on. + useSettingsStore.setState({ autoSelectReplyIdentity: true }); + useAuthStore.setState({ client: null }); + }); + + it('shows the lock and makes the control inert for each locked row', () => { + setPolicy(Object.keys(ROWS)); + render(); + for (const label of Object.values(ROWS)) { + const item = row(label); + expect(within(item).getByText('org_locked'), label).toBeTruthy(); + const control = item.querySelector('[role="switch"], select'); + expect(control, label).not.toBeNull(); + expect(control!.closest('[inert]'), label).not.toBeNull(); + } + }); + + it('leaves the rows editable without a lock', () => { + setPolicy([]); + render(); + for (const label of Object.values(ROWS)) { + const item = row(label); + expect(within(item).queryByText('org_locked'), label).toBeNull(); + expect(item.querySelector('[inert]'), label).toBeNull(); + } + }); + + it('hides the rows the organisation hides', () => { + setPolicy([], Object.keys(ROWS)); + render(); + for (const label of Object.values(ROWS)) expect(screen.queryByText(label), label).toBeNull(); + }); +}); diff --git a/components/settings/composing-settings.tsx b/components/settings/composing-settings.tsx index becf7296b..d49f531ea 100644 --- a/components/settings/composing-settings.tsx +++ b/components/settings/composing-settings.tsx @@ -20,6 +20,8 @@ export function ComposingSettings() { const t = useTranslations('settings.email_behavior'); const tPage = useTranslations('settings.composing_page'); const templatesEnabled = usePolicyStore((s) => s.isFeatureEnabled('templatesEnabled')); + // The admin panel can lock or hide these three (restriction-keys.ts). + const { isSettingLocked, isSettingHidden } = usePolicyStore(); const [newKeyword, setNewKeyword] = useState(''); const { @@ -49,12 +51,14 @@ export function ComposingSettings() { return (
- + {!isSettingHidden('plainTextMode') && ( + updateSetting('plainTextMode', checked)} /> + )} - + {!isSettingHidden('autoSelectReplyIdentity') && ( + updateSetting('autoSelectReplyIdentity', checked)} /> + )} - {autoSelectReplyIdentity && customFromEnabled && ( - + {autoSelectReplyIdentity && customFromEnabled && !isSettingHidden('replyIdentityMatch') && ( +