From bc5302568d679aba57732bd05c28654d01c87844 Mon Sep 17 00:00:00 2001 From: dealerweb Date: Tue, 15 Sep 2026 12:29:55 +0200 Subject: [PATCH] fix(calendar): wait for the JMAP client before loading the account principal The calendar loads the principal's alias addresses so an alias-organised event is recognised as the user's own. The fetch fired on mount, but after a page load the JMAP client is restored asynchronously: the call failed with "Not authenticated" in the console, and the once-only guard then blocked the retry for the rest of the session, leaving the aliases empty. Move the logic into useAccountPrincipalEmails, which waits for the client and fetches once, and cover the sequence with a hook test. --- components/calendar/calendar-app.tsx | 16 ++---- .../use-account-principal-emails.test.tsx | 56 +++++++++++++++++++ hooks/use-account-principal-emails.ts | 29 ++++++++++ 3 files changed, 89 insertions(+), 12 deletions(-) create mode 100644 hooks/__tests__/use-account-principal-emails.test.tsx create mode 100644 hooks/use-account-principal-emails.ts diff --git a/components/calendar/calendar-app.tsx b/components/calendar/calendar-app.tsx index 215ca19d7..b9eba1af4 100644 --- a/components/calendar/calendar-app.tsx +++ b/components/calendar/calendar-app.tsx @@ -15,10 +15,10 @@ import { useEmailStore } from "@/stores/email-store"; import { useSettingsStore } from "@/stores/settings-store"; import { useIdentityStore } from "@/stores/identity-store"; import { useAccountStore } from "@/stores/account-store"; -import { useAccountSecurityStore } from "@/stores/account-security-store"; import { usePolicyStore } from "@/stores/policy-store"; import { toast } from "@/stores/toast-store"; import { useIsDesktop, useIsMobile } from "@/hooks/use-media-query"; +import { useAccountPrincipalEmails } from "@/hooks/use-account-principal-emails"; import { Button } from "@/components/ui/button"; import { CalendarToolbar } from "@/components/calendar/calendar-toolbar"; import { CalendarMonthView } from "@/components/calendar/calendar-month-view"; @@ -130,17 +130,9 @@ export function CalendarApp({ linkSegments }: CalendarAppProps = {}) { const contacts = useContactStore((s) => s.contacts); const normalizedViewMode = isCalendarViewMode(viewMode) ? viewMode : "month"; - // Aliases live on the principal, not on identities; fetch them so an - // alias-organized event is recognised as the user's own (see isOrganizer). - const accountEmails = useAccountSecurityStore((s) => s.emails); - const fetchPrincipal = useAccountSecurityStore((s) => s.fetchPrincipal); - const principalFetchedRef = useRef(false); - useEffect(() => { - if (principalFetchedRef.current) return; - principalFetchedRef.current = true; - if (accountEmails.length > 0) return; // already loaded elsewhere - void fetchPrincipal(); - }, [accountEmails, fetchPrincipal]); + // Aliases live on the principal, not on identities; the calendar needs them + // so an alias-organized event is recognised as the user's own (see isOrganizer). + const accountEmails = useAccountPrincipalEmails(client); // The default ParticipantIdentity (draft-ietf-jmap-calendars ยง6) is the // address new invitations are organised from, so it goes first: the event diff --git a/hooks/__tests__/use-account-principal-emails.test.tsx b/hooks/__tests__/use-account-principal-emails.test.tsx new file mode 100644 index 000000000..f98c96a8e --- /dev/null +++ b/hooks/__tests__/use-account-principal-emails.test.tsx @@ -0,0 +1,56 @@ +import { render, act, cleanup } from '@testing-library/react'; +import { describe, it, expect, beforeEach, afterEach, vi, type Mock } from 'vitest'; +import { useAccountPrincipalEmails } from '../use-account-principal-emails'; +import { useAccountSecurityStore } from '@/stores/account-security-store'; +import type { IJMAPClient } from '@/lib/jmap/client-interface'; + +// The calendar needs the principal's alias addresses to recognise an +// alias-organised event as the user's own. The JMAP client is restored +// asynchronously after a page load, so the fetch has to wait for it: firing +// on mount failed with "Not authenticated", and the once-only guard then +// never retried for the rest of the session. + +let seen: string[] = []; + +function Harness({ client }: { client: IJMAPClient | null }) { + seen = useAccountPrincipalEmails(client); + return null; +} + +const client = {} as IJMAPClient; +let fetchPrincipal: Mock<() => Promise>; + +beforeEach(() => { + seen = []; + fetchPrincipal = vi.fn<() => Promise>().mockResolvedValue(undefined); + useAccountSecurityStore.setState({ emails: [], fetchPrincipal }); +}); + +afterEach(cleanup); + +describe('useAccountPrincipalEmails', () => { + it('waits for the JMAP client instead of failing on mount', () => { + const { rerender } = render(); + expect(fetchPrincipal).not.toHaveBeenCalled(); + + rerender(); + expect(fetchPrincipal).toHaveBeenCalledTimes(1); + }); + + it('fetches once per mount, not on every render or store update', () => { + const { rerender } = render(); + rerender(); + act(() => { + useAccountSecurityStore.setState({ emails: ['me@example.com', 'alias@example.com'] }); + }); + expect(fetchPrincipal).toHaveBeenCalledTimes(1); + expect(seen).toEqual(['me@example.com', 'alias@example.com']); + }); + + it('skips the fetch when the principal is already loaded elsewhere', () => { + useAccountSecurityStore.setState({ emails: ['me@example.com'] }); + render(); + expect(fetchPrincipal).not.toHaveBeenCalled(); + expect(seen).toEqual(['me@example.com']); + }); +}); diff --git a/hooks/use-account-principal-emails.ts b/hooks/use-account-principal-emails.ts new file mode 100644 index 000000000..62a734c68 --- /dev/null +++ b/hooks/use-account-principal-emails.ts @@ -0,0 +1,29 @@ +import { useEffect, useRef } from 'react'; +import type { IJMAPClient } from '@/lib/jmap/client-interface'; +import { useAccountSecurityStore } from '@/stores/account-security-store'; + +/** + * The account's principal addresses - its primary address plus the enabled + * aliases - loaded on first use. Aliases live on the Stalwart principal, not + * on the identities, so a view that must recognise an alias-organised event + * as the user's own (the calendar's isOrganizer) needs this list. + * + * The fetch waits for the JMAP client. After a page load the client is + * restored asynchronously; asking for the principal before it exists only + * fails with "Not authenticated", and a once-only guard that fires on mount + * then blocks the retry for the rest of the session. + */ +export function useAccountPrincipalEmails(client: IJMAPClient | null): string[] { + const emails = useAccountSecurityStore((s) => s.emails); + const fetchPrincipal = useAccountSecurityStore((s) => s.fetchPrincipal); + const fetchedRef = useRef(false); + + useEffect(() => { + if (!client || fetchedRef.current) return; + fetchedRef.current = true; + if (emails.length > 0) return; // already loaded elsewhere + void fetchPrincipal(); + }, [client, emails, fetchPrincipal]); + + return emails; +}