mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-01 08:12:43 +00:00
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:
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user