Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,19 @@ Central manager only ever sends the boolean, never the other organizations' ids;
an expired session — and let still-mounted pages rebuild their queries after the sign-out's cache clear and fetch
again on the dead session. The guard redirects every signed-out dashboard load, so the spinner only covers the moment
before it does (`endedSessionRouteFailures.test.tsx`, HarperFast/studio#1546).
- **Signing out in one tab signs out every tab.** `signOutAllLocally` rewrites `Studio:AppSignOut`, and each other tab
holding the app sign-in runs `clearAuthStateAfterSignOutElsewhere` on that `storage` event (`installCrossTabSignOut`).
Without it, a tab learns the session ended only from its next central-manager request, whose 403 toasts as a
permission refusal. `signOutAllLocally` runs once the shared session has ended: after `/Logout/` succeeds, on a 401,
after a password change. The sign-out sweep does not announce, because it runs before `/Logout/`: a failed logout
would sign the other tabs out of a live session, and one signed back in meanwhile would be ended by that `/Logout/`.
The value names the user whose session ended (the sweep remembers them, since it clears the user before `/Logout/`),
and a tab follows only when that is its own app user: a late 401 or event from user U's session must not sign out a
tab that has since signed in as V. A tab whose check is still out remembers the user, and if the check comes back as
them, asks again: central manager may have confirmed them just before the session ended.
Tabs follow that key, not the marker record, because a tab also drops the app sign-in from the record when its own
check merely fails (a timeout, a 5xx). A tab that follows writes no `localStorage`: its copy of the markers is a
snapshot from its own load, and writing it back could undo the other tab's next sign-in (`crossTabSignOut.test.tsx`).
- **Data fetching**: TanStack React Query 5. Query keys must be instance-scoped when the request targets a specific instance.
- **Polling**: every `refetchInterval` on a query that can reject against an instance or a cluster goes through [`pollUnlessForbidden`](src/react-query/pollUnlessForbidden.ts), which stops the timer on a 403 and backs off a sustained 400 or 5xx; a raw number polls a failing endpoint at full rate for as long as the page is open. When the interval depends on the data, compute it and hand it over per tick: `(query) => pollUnlessForbidden(base(query))(query)` (the back-off state is per query, not per wrapper).
- **Forms**: react-hook-form with a zod `zodResolver`. A form whose submit button waits on `formState.isValid` must not use the default `onSubmit` mode, which keeps `isValid` current but shows errors only after a submit the disabled button never allows, so the user is blocked with no reason given (HarperFast/studio#1550). Use `mode: 'onTouched'`: a field's error appears once it is first left and then follows each change, while `isValid` still tracks every keystroke (`onBlur` recomputes `isValid` only on blur, so the button lags the text). Enter neither submits past a disabled button nor leaves the field, so give the `<form>` `onKeyDown={revealFieldErrorOnEnter(form)}`, which treats Enter in a text input as leaving it. A refine that compares two fields reports on one of them; re-check that field from the other's `onChange` once it has been touched (`getFieldState(name).isTouched`, then `trigger(name)`), because `rules={{ deps }}` fires on the other field's first blur, before the user reaches it. react-hook-form only updates the `formState` fields a render has read, and `disabled={!isDirty || !isValid}` skips `isValid` while the form is pristine, so where one change can complete the form (a single field, a paste, a checkbox) the button stays disabled on a valid form. Read the fields before the JSX (`const { isDirty, isValid } = form.formState;`) and pin it with a test that makes one change and checks the button.
Expand Down
29 changes: 20 additions & 9 deletions src/features/auth/handlers/clearAuthStateLocally.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,26 @@ import { queryClient } from '@/react-query/queryClient';
* same-tab sign-in as another user.
*/
export function clearAuthStateLocally(): void {
for (
const step of [
() => authStore.signOutAllLocally(),
() => queryClient.getMutationCache().clear(),
() => queryClient.getQueryCache().clear(),
clearLocalStorage,
clearSessionStorage,
]
) {
runEvery([
() => authStore.signOutAllLocally(),
() => queryClient.getMutationCache().clear(),
() => queryClient.getQueryCache().clear(),
clearLocalStorage,
clearSessionStorage,
]);
}

export function clearAuthStateAfterSignOutElsewhere(): void {
runEvery([
() => authStore.forgetAppSignInEndedElsewhere(),
() => queryClient.getMutationCache().clear(),
() => queryClient.getQueryCache().clear(),
clearSessionStorage,
]);
}

function runEvery(steps: Array<() => void>): void {
for (const step of steps) {
try {
step();
} catch (error) {
Expand Down
35 changes: 34 additions & 1 deletion src/features/auth/handlers/signOutHandlers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import { authStore, OverallAppSignIn } from '@/features/auth/store/authStore';
import { queryClient } from '@/react-query/queryClient';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { clearAuthStateLocally } from './clearAuthStateLocally';
import { clearAuthStateAfterSignOutElsewhere, clearAuthStateLocally } from './clearAuthStateLocally';
import { logoutOnSuccess } from './logoutOnSuccess';

type SetArgs = Parameters<typeof authStore.setUserForIdAndKey>;
Expand Down Expand Up @@ -105,3 +105,36 @@ describe.each(
expect(consoleError).toHaveBeenCalledWith(failure);
});
});

describe('clearAuthStateAfterSignOutElsewhere', () => {
it('signs this tab out and wipes both caches and its sessionStorage, but leaves the shared localStorage', () => {
seedSignedInTab();
// The tab that signed out cleared localStorage; what is there now is that tab's, a new sign-in perhaps.
localStorage.clear();
localStorage.setItem('Studio:PotentiallyAuthenticated', JSON.stringify({ OverallAppSignIn }));

clearAuthStateAfterSignOutElsewhere();

expect(authStore.getConnectionById(OverallAppSignIn).user).toBeNull();
expect(authStore.getConnectionById('ins-a').user).toBeNull();
expectCachesEmpty();
expect(sessionStorage.length).toBe(0);
expect(localStorage.length).toBe(1);
expect(localStorage.getItem('Studio:PotentiallyAuthenticated')).toBe(JSON.stringify({ OverallAppSignIn }));
});

it('still wipes the caches and sessionStorage when the store teardown throws', () => {
seedSignedInTab();
const failure = new Error('teardown failed');
vi.spyOn(authStore, 'forgetAppSignInEndedElsewhere').mockImplementation(() => {
throw failure;
});
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined);

clearAuthStateAfterSignOutElsewhere();

expectCachesEmpty();
expect(sessionStorage.length).toBe(0);
expect(consoleError).toHaveBeenCalledWith(failure);
});
});
199 changes: 199 additions & 0 deletions src/features/auth/store/appSignOutElsewhere.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
/** @vitest-environment jsdom */
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

const { createInstanceAuthenticationTokens, getInstanceUserInfo } = vi.hoisted(() => ({
createInstanceAuthenticationTokens: vi.fn(),
getInstanceUserInfo: vi.fn(),
}));
vi.mock('@/config/apiClient', () => ({ apiClient: { get: vi.fn(), defaults: { baseURL: 'https://cm.example.com' } } }));
vi.mock('@/integrations/api/instance/auth/createInstanceAuthenticationTokens', () => ({
createInstanceAuthenticationTokens,
refreshInstanceOperationToken: vi.fn(),
}));
vi.mock('@/integrations/api/instance/status/getInstanceUserInfo', () => ({ getInstanceUserInfo }));

import { apiClient } from '@/config/apiClient';
import { authStore, OverallAppSignIn } from './authStore';

type SetArgs = Parameters<typeof authStore.setUserForIdAndKey>;

const MARKER = 'Studio:PotentiallyAuthenticated';
const SIGNAL = 'Studio:AppSignOut';
const DIRECT_URL = 'https://ins-direct.example.com:9925/';

function signIn() {
authStore.setUserForIdAndKey(OverallAppSignIn, OverallAppSignIn, { id: 'usr_a' } as SetArgs[2]);
}

function storageEventFromAnotherTab(key: string | null, newValue: string | null) {
window.dispatchEvent(new StorageEvent('storage', { key, newValue, storageArea: localStorage }));
}

function signOutOf(user: unknown) {
return JSON.stringify({ user, at: '1' });
}

function announcedUser() {
const value = localStorage.getItem(SIGNAL);
return value === null ? undefined : (JSON.parse(value) as { user: unknown }).user;
}

beforeEach(() => {
localStorage.clear();
sessionStorage.clear();
vi.spyOn(console, 'debug').mockImplementation(() => undefined);
});

afterEach(() => {
authStore.signOutAllLocally();
vi.resetAllMocks();
vi.restoreAllMocks();
});

describe('authStore.onAppSignedOutElsewhere', () => {
it('fires for another tab’s sign-out of this tab’s user, and for nothing else it writes', () => {
signIn();
const signedOut = vi.fn();
const stop = authStore.onAppSignedOutElsewhere(signedOut);

// A cluster sign-out, a failed sign-in check dropping the app marker, an explorer clear, a storage wipe.
storageEventFromAnotherTab(MARKER, JSON.stringify({ OverallAppSignIn }));
storageEventFromAnotherTab(MARKER, '{}');
storageEventFromAnotherTab('Studio:ExplorerAuthEpoch', JSON.stringify({ '*': 1 }));
storageEventFromAnotherTab(null, null);
// Someone else's session, a sign-out that knew no user, and values no sign-out writes.
storageEventFromAnotherTab(SIGNAL, signOutOf('usr_v'));
storageEventFromAnotherTab(SIGNAL, signOutOf(null));
storageEventFromAnotherTab(SIGNAL, '1');
storageEventFromAnotherTab(SIGNAL, 'null');
storageEventFromAnotherTab(SIGNAL, '{');
expect(signedOut).not.toHaveBeenCalled();

storageEventFromAnotherTab(SIGNAL, signOutOf('usr_a'));
expect(signedOut).toHaveBeenCalledOnce();
stop();
});

it('does nothing in a tab that holds no app sign-in', () => {
const signedOut = vi.fn();
const stop = authStore.onAppSignedOutElsewhere(signedOut);

storageEventFromAnotherTab(SIGNAL, signOutOf('usr_a'));
storageEventFromAnotherTab(SIGNAL, signOutOf(null));

expect(signedOut).not.toHaveBeenCalled();
stop();
});

it.each([
['the user it named', 'usr_a', 2],
['someone else', 'usr_v', 1],
])('asks again when a check that was out during a sign-out of %s comes back as them', async (_, ended, checks) => {
signIn();
let answer: (response: { data: { id: string } }) => void = () => undefined;
vi.mocked(apiClient.get)
.mockReturnValueOnce(new Promise((resolve) => (answer = resolve)))
.mockReturnValue(new Promise(() => undefined));
const check = authStore.reloadUser(OverallAppSignIn);
const signedOut = vi.fn();
const stop = authStore.onAppSignedOutElsewhere(signedOut);

storageEventFromAnotherTab(SIGNAL, signOutOf(ended));
answer({ data: { id: 'usr_a' } });
await check;

expect(signedOut).not.toHaveBeenCalled();
expect(apiClient.get).toHaveBeenCalledTimes(checks);
stop();
});

it('does not ask again on a later check in a tab that was signed out when it heard', async () => {
const stop = authStore.onAppSignedOutElsewhere(vi.fn());
storageEventFromAnotherTab(SIGNAL, signOutOf('usr_a'));
signIn();
vi.mocked(apiClient.get).mockResolvedValueOnce({ data: { id: 'usr_a' } }).mockReturnValue(
new Promise(() => undefined),
);

await authStore.reloadUser(OverallAppSignIn);

expect(apiClient.get).toHaveBeenCalledOnce();
stop();
});

it('still names the ended user when the teardown throws', () => {
signIn();
const failure = new Error('explorer listener failed');
const stop = authStore.onExplorerAuthInvalidated(OverallAppSignIn, () => {
throw failure;
});

expect(() => authStore.signOutAllLocally()).toThrow(failure);
stop();

expect(announcedUser()).toBe('usr_a');
});

it('names the ended user from every local sign-out, and is not written by the sweep before `/Logout/`', async () => {
signIn();
await authStore.signOutFromPotentiallyAuthenticatedInstances();
expect(localStorage.getItem(SIGNAL)).toBeNull();
// `logoutOnSuccess`, after the sweep has already cleared the user.
authStore.signOutAllLocally();
expect(announcedUser()).toBe('usr_a');

signIn();
authStore.signOutAllLocally();
const first = localStorage.getItem(SIGNAL);
signIn();
authStore.signOutAllLocally();
expect(announcedUser()).toBe('usr_a');
expect(localStorage.getItem(SIGNAL)).not.toBe(first);

// A tab with no user to name: nothing for another tab to match.
authStore.signOutAllLocally();
expect(announcedUser()).toBeNull();
});
});

describe('authStore.forgetAppSignInEndedElsewhere', () => {
it('signs every entity out in memory without writing the storage the tabs share', () => {
signIn();
authStore.setUserForIdAndKey('ins-a', 'https://ins-a.example.com', { username: 'u' } as SetArgs[2]);
// What the other tab wrote after signing out: a sign-in, perhaps as someone else.
localStorage.clear();
localStorage.setItem(MARKER, JSON.stringify({ OverallAppSignIn }));
const writes = [
vi.spyOn(Storage.prototype, 'setItem'),
vi.spyOn(Storage.prototype, 'removeItem'),
vi.spyOn(Storage.prototype, 'clear'),
];

authStore.forgetAppSignInEndedElsewhere();

expect(authStore.isKnownSignedOut(OverallAppSignIn)).toBe(true);
expect(authStore.getConnectionById('ins-a').user).toBeNull();
expect(authStore.getOperationsUrl('ins-a')).toBeUndefined();
const localWrites = writes.flatMap((spy) => spy.mock.contexts).filter((storage) => storage === localStorage);
expect(localWrites).toEqual([]);
expect(localStorage.getItem(MARKER)).toBe(JSON.stringify({ OverallAppSignIn }));
});

it('drops a direct-connect token, and a sign-in check still in flight', async () => {
signIn();
createInstanceAuthenticationTokens.mockResolvedValue({ operationToken: 'jwt', refreshToken: 'refresh' });
getInstanceUserInfo.mockResolvedValueOnce({ username: 'direct' });
await authStore.establishFabricConnectAuth({ id: 'ins-direct', operationsUrl: DIRECT_URL });
expect(authStore.getOperationToken('ins-direct')).toBe('jwt');
let answerCheck: (user: { username: string }) => void = () => undefined;
getInstanceUserInfo.mockReturnValueOnce(new Promise((resolve) => (answerCheck = resolve)));
const check = authStore.reloadUser('ins-direct');

authStore.forgetAppSignInEndedElsewhere();
answerCheck({ username: 'direct' });

await expect(check).resolves.toBeNull();
expect(authStore.getOperationToken('ins-direct')).toBeUndefined();
expect(authStore.getConnectionById('ins-direct').user).toBeNull();
});
});
Loading
Loading