diff --git a/.changeset/tangy-ducks-end.md b/.changeset/tangy-ducks-end.md new file mode 100644 index 00000000000..fd6f69209d7 --- /dev/null +++ b/.changeset/tangy-ducks-end.md @@ -0,0 +1,5 @@ +--- +'@clerk/ui': patch +--- + +Scope the `UserProfile` active-devices fetch cache by `user.id` so a session switch or sign-out/sign-in on a shared device no longer renders the previous user's device activity (IP, location, browser/device) from the module-scoped cache. diff --git a/packages/ui/src/components/UserProfile/ActiveDevicesSection.tsx b/packages/ui/src/components/UserProfile/ActiveDevicesSection.tsx index a268c3aa22f..a8215cd8c94 100644 --- a/packages/ui/src/components/UserProfile/ActiveDevicesSection.tsx +++ b/packages/ui/src/components/UserProfile/ActiveDevicesSection.tsx @@ -17,7 +17,7 @@ export const ActiveDevicesSection = () => { const { user } = useUser(); const { session } = useSession(); - const { data: sessions, isLoading } = useFetch(user?.getSessions, 'user-sessions'); + const { data: sessions, isLoading } = useFetch(user?.getSessions, { userId: user?.id }, undefined, 'user-sessions'); return ( { } }); }); + + it('does not leak the previous user device activity across a user switch', async () => { + const makeSession = (sessionId: string, city: string) => + ({ + pathRoot: '/me/sessions', + id: sessionId, + status: 'active', + expireAt: '2022-12-01T01:55:44.636Z', + abandonAt: '2022-12-24T01:55:44.636Z', + lastActiveAt: '2022-11-24T12:11:49.328Z', + latestActivity: { + id: 'sess_activity_1', + deviceType: 'Macintosh', + browserName: 'Chrome', + browserVersion: '107.0.0.0', + country: 'Greece', + city, + isMobile: false, + }, + actor: null, + revoke: vi.fn().mockResolvedValue({}), + }) as any as SessionWithActivitiesResource; + + // User A renders the Security page, caching their sessions in the + // module-scoped `useFetch` cache. + const { wrapper: wrapperA, fixtures: fixturesA } = await createFixtures(f => { + f.withUser({ id: 'user_a', email_addresses: ['a@clerk.com'] }); + }); + fixturesA.clerk.user!.getSessions.mockReturnValue( + Promise.resolve([makeSession(fixturesA.clerk.session!.id, 'Athens')]), + ); + + const { unmount } = render(, { wrapper: wrapperA }); + await waitFor(() => expect(fixturesA.clerk.user?.getSessions).toHaveBeenCalled()); + await screen.findByText(/Athens/i); + unmount(); + + // User B mounts the Security page within the stale window. Because the cache + // is keyed by `user.id`, B must trigger a fresh fetch and never sees A's + // cached device activity. + const { wrapper: wrapperB, fixtures: fixturesB } = await createFixtures(f => { + f.withUser({ id: 'user_b', email_addresses: ['b@clerk.com'] }); + }); + fixturesB.clerk.user!.getSessions.mockReturnValue( + Promise.resolve([makeSession(fixturesB.clerk.session!.id, 'Berlin')]), + ); + + render(, { wrapper: wrapperB }); + await waitFor(() => expect(fixturesB.clerk.user?.getSessions).toHaveBeenCalled()); + await screen.findByText(/Berlin/i); + expect(screen.queryByText(/Athens/i)).not.toBeInTheDocument(); + }); });