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.
This commit is contained in:
dealerweb
2026-09-15 12:44:36 +02:00
parent 2df64024bd
commit bc5302568d
3 changed files with 89 additions and 12 deletions
+4 -12
View File
@@ -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
@@ -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<void>>;
beforeEach(() => {
seen = [];
fetchPrincipal = vi.fn<() => Promise<void>>().mockResolvedValue(undefined);
useAccountSecurityStore.setState({ emails: [], fetchPrincipal });
});
afterEach(cleanup);
describe('useAccountPrincipalEmails', () => {
it('waits for the JMAP client instead of failing on mount', () => {
const { rerender } = render(<Harness client={null} />);
expect(fetchPrincipal).not.toHaveBeenCalled();
rerender(<Harness client={client} />);
expect(fetchPrincipal).toHaveBeenCalledTimes(1);
});
it('fetches once per mount, not on every render or store update', () => {
const { rerender } = render(<Harness client={client} />);
rerender(<Harness client={client} />);
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(<Harness client={client} />);
expect(fetchPrincipal).not.toHaveBeenCalled();
expect(seen).toEqual(['me@example.com']);
});
});
+29
View File
@@ -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;
}