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
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -784,8 +784,8 @@ Three traps when reading this from RUM data:

A boot-time redirect does **not** cost you that first view, and the deciding factor is whether
`getAllConnections()` can answer synchronously. It synthesizes `{ user: null, isLoading: false }`
for `OverallAppSignIn` only when the `Studio:PotentiallyAuthenticated` localStorage record has no
entry for it; with an entry it returns the record untouched, so the key is simply **absent**.
for `OverallAppSignIn` only when no `Studio:PotentiallyAuthenticated:OverallAppSignIn` marker is
stored; with one it returns the record untouched, so the key is simply **absent**.
`dashboardLayout.beforeLoad` guards on `auth && !auth.isLoading && !auth.user`, so those are two
different outcomes: a signed-out deep link redirects _during_ the router's initial load and the root
component never commits the deep-link location — one `startView`, named `/sign-in/` — while an
Expand Down
33 changes: 28 additions & 5 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,9 +109,32 @@ Central manager only ever sends the boolean, never the other organizations' ids;
its own route: TanStack resolves it against the _current_ location, so a row that outlives a pending
navigation re-resolves its href one level too deep (HarperFast/studio#1710, #1711).
`EntityMenuItem.to` accepts only the helper's `AbsolutePath`, so a relative menu target fails `tsc`.
- **Remembered instance/cluster sign-ins are checked on demand**: `Studio:PotentiallyAuthenticated` spans every
organization ever visited, so the root auth subscription checks only the app sign-in; an entity is checked by
- **Remembered instance/cluster sign-ins are checked on demand**: the sign-in markers span every organization ever
visited, so the root auth subscription checks only the app sign-in; an entity is checked by
`useInstanceAuth`/`listenToEntity` or the instance-layout guard (`entityAuthCheckScope.test.ts`, HarperFast/studio#1329).
- **Each sign-in marker has its own key, `Studio:PotentiallyAuthenticated:<entityId>`, holding the URL signed into**
(`signInMarkers.ts`, HarperFast/studio#1968). A tab writes only the entity it signs in or out, so tabs can't erase or
restore each other's markers; app and full sign-outs act on every stored marker, at each URL an entity is marked at
here or in storage. A check never undoes another tab's change: a passing one of a sign-in this tab already marked
writes nothing, and a failing one removes only the marker it checked. So a marker removed elsewhere stays removed
while this tab still holds that sign-in, until this tab signs into the entity again, which always writes; for the
app sign-in, which every `/User/current` answer re-signs, those answers count as checks. A check's answer, or a
`/User/current` answer, is dropped when this tab signed in again while it was out (`crossTabMarkers.test.ts`,
`getCurrentUser.test.ts`). A sign-in storage refuses to record still signs in, and its next sign-in or check writes
it; a marker storage won't remove is retried at every marker write, unless another tab has rewritten it since
(`localSignOutStorageFailure.test.ts`).
- **The previous release's one shared record is kept in step for one more release.** Its tabs read
`Studio:PotentiallyAuthenticated` once at load and write their whole copy back, and know nothing of the per-entity
keys. So this release copies that record into the keys once per storage (a marker already stored per entity wins),
mirrors every marker change into it, and reads it in every sign-out sweep. A previous-release tab, or a rollback,
therefore still sees this release's sign-ins and sign-outs, and an app sign-out here reaches a sign-in only that tab
recorded. Its sign-in reaches this release's keys only at that first copy, and its sign-out of an entity doesn't
remove the entity's key, which matters only if its logout also failed; its full sign-out wipes both. The copy is
never repeated, because that tab's whole-copy writes would bring back what this release signed out, so the sign-out
wipe keeps the flag that records it. Until the copy lands, as when a full quota refuses it, the record also seeds
this tab's markers (`legacySignInMarkers.test.ts`, `account-menu.anon.spec.ts`). Every load also removes a record
that isn't one, which the previous release can't start on, and drops entries that aren't markers; a sign-in leaves
one it can't read alone. The release after this one stops the mirror and removes the record (HarperFast/studio#1970).
- **`Dashboard` renders nothing under it without a signed-in user.** Route data loads in parallel with the page load's
`/User/current` check (waiting for it costs +262/680 ms p75 on live org/cluster cold loads), and central manager
answers a request with no session with 403, so a deep link on an expired session has its route data fail before the
Expand All @@ -132,9 +155,9 @@ Central manager only ever sends the boolean, never the other organizations' ids;
from their next request. A tab follows only when the name 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`).
Tabs follow that key, not the app sign-in's marker, because a tab also drops that marker when its own check merely
fails (a timeout, a 5xx). A tab that follows writes no `localStorage`: the other tab already wiped it, and a write
could undo that 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
2 changes: 1 addition & 1 deletion e2e/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ Naming drives which project runs a spec: `*.anon.spec.ts` = no session,
- **Hash routing.** Routes are `/#/sign-in`, `/#/verifying`, `/#/<orgId>/users`.
- **No test-ids** in these flows — drive by role/label/text (`getByRole`, `getByLabel`).
The only `data-testid`s in the app are in instance analytics.
- **Auth is a cookie** (`POST /Login/`); the `Studio:PotentiallyAuthenticated`
- **Auth is a cookie** (`POST /Login/`); the `Studio:PotentiallyAuthenticated:<entity>`
localStorage flag is only a hint. `storageState` captures the cookie.
- **Sign-in and sign-up render every submit failure inline**
(`p[role="alert"][data-slot="form-message"]`). **Forgot-password renders only CAPTCHA rejections
Expand Down
56 changes: 52 additions & 4 deletions e2e/tests/account-menu.anon.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { expect, test } from '@playwright/test';
import { appSignInMarker, rememberAppSignIn } from './signInMarkers';

const role = (name: string) => ({ organizationName: name, role: 'owner', permission: { super_user: true } });
const user = {
Expand All @@ -18,9 +19,7 @@ test.beforeEach(async ({ page }) => {
['fetch', 'xhr'].includes(route.request().resourceType())
? route.fulfill({ status: 404, json: { error: 'Unmocked test request' } })
: route.continue());
await page.addInitScript(() =>
localStorage.setItem('Studio:PotentiallyAuthenticated', JSON.stringify({ OverallAppSignIn: 'fixture' }))
);
await rememberAppSignIn(page);
await page.route('**/User/current', route => route.fulfill({ json: user }));
await page.route('**/Organization/*', route => {
const id = route.request().url().split('/').at(-1)!;
Expand Down Expand Up @@ -87,7 +86,56 @@ test('account menu groups profile, organization switching, appearance and sign o
await page.getByRole('menuitem', { name: 'Sign Out', exact: true }).click();
await expect(page).toHaveURL(/#\/sign-in/);
expect(signedOut).toBe(true);
expect(await page.evaluate(() => localStorage.getItem('Studio:PotentiallyAuthenticated'))).toBeNull();
expect(await page.evaluate((key) => localStorage.getItem(key), appSignInMarker)).toBeNull();
});

test.describe('alongside a tab still on the previous release, which keeps every sign-in in one record', () => {
const legacyRecord = 'Studio:PotentiallyAuthenticated';

test('stays signed in on the sign-in that release recorded', async ({ page }) => {
await page.addInitScript(({ marker, legacy }) => {
if (!localStorage.getItem('Studio:PotentiallyAuthenticatedMigrated')) {
localStorage.removeItem(marker);
localStorage.setItem(legacy, JSON.stringify({ OverallAppSignIn: 'fixture' }));
}
}, { marker: appSignInMarker, legacy: legacyRecord });

await page.goto('/#/org-a');

await expect(page.getByRole('button', { name: 'Account menu' })).toBeVisible();
expect(await page.evaluate((key) => localStorage.getItem(key), appSignInMarker)).toBeTruthy();
});

test('signs out of a server only that tab signed into', async ({ page }) => {
const oldTabSignIn = 'https://ins-old.example.test:9925/';
const instanceRequests: string[] = [];
await page.route(/ins-old\.example\.test/, route => {
instanceRequests.push(`${route.request().method()} ${route.request().postData() ?? ''}`);
return route.fulfill({
json: { message: 'ok' },
headers: {
'Access-Control-Allow-Origin': route.request().headers()['origin'] ?? '*',
'Access-Control-Allow-Credentials': 'true',
'Access-Control-Allow-Headers': 'content-type',
},
});
});
await page.route(/\/Logout\/?$/, route => route.fulfill({ json: {} }));
await page.goto('/#/org-a');
await expect(page.getByRole('button', { name: 'Account menu' })).toBeVisible();
// That tab writes its whole record back after this one has loaded.
await page.evaluate(
({ legacy, url }) =>
localStorage.setItem(legacy, JSON.stringify({ OverallAppSignIn: 'fixture', 'ins-old': url })),
{ legacy: legacyRecord, url: oldTabSignIn },
);

await page.getByRole('button', { name: 'Account menu' }).click();
await page.getByRole('menuitem', { name: 'Sign Out', exact: true }).click();

await expect(page).toHaveURL(/#\/sign-in/);
expect(instanceRequests).toContain('POST {"operation":"logout"}');
});
});

test('account controls remain reachable on a narrow screen with keyboard focus return', async ({ page }) => {
Expand Down
2 changes: 1 addition & 1 deletion e2e/tests/auth.setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ const password = process.env.PLAYWRIGHT_USER_PASSWORD;

/**
* Logs in through the real UI once and persists the session (the cookie set by
* POST /Login/ — the localStorage `Studio:PotentiallyAuthenticated` flag is only
* POST /Login/ — the localStorage `Studio:PotentiallyAuthenticated:*` flags are only
* a hint, so cookie-bearing storageState is what actually authenticates).
*
* Doubles as the sign-in happy-path smoke test.
Expand Down
5 changes: 2 additions & 3 deletions e2e/tests/cluster-form.anon.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { expect, test } from '@playwright/test';
import { rememberAppSignIn } from './signInMarkers';

const region = { id: 'us-1', region: 'US', latencyDescription: '83ms', instanceCount: 2 };
const plan = {
Expand Down Expand Up @@ -33,9 +34,7 @@ const organizationWith = (clusterRecord: typeof cluster) => ({
});

test.beforeEach(async ({ page }) => {
await page.addInitScript(() =>
localStorage.setItem('Studio:PotentiallyAuthenticated', JSON.stringify({ OverallAppSignIn: 'fixture' }))
);
await rememberAppSignIn(page);
await page.route(
'**/*',
route =>
Expand Down
5 changes: 2 additions & 3 deletions e2e/tests/clusters.anon.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { expect, test } from '@playwright/test';
import { rememberAppSignIn } from './signInMarkers';

const clusters = [
{ id: 'clu-production', name: 'Production', status: 'RUNNING', regionId: 'reg-east' },
Expand Down Expand Up @@ -26,9 +27,7 @@ const user = {
};

test.beforeEach(async ({ page }) => {
await page.addInitScript(() => {
localStorage.setItem('Studio:PotentiallyAuthenticated', JSON.stringify({ OverallAppSignIn: 'fixture' }));
});
await rememberAppSignIn(page);
await page.route('**/User/current', route => route.fulfill({ json: user }));
await page.route('**/Organization/org-fixture', route => route.fulfill({ json: organization }));
await page.route('**/Cluster/clu-production', route => route.fulfill({ json: { ...clusters[0], instances: [] } }));
Expand Down
5 changes: 2 additions & 3 deletions e2e/tests/global-search.anon.spec.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
import { expect, test } from '@playwright/test';
import { rememberAppSignIn } from './signInMarkers';

const role = (organizationName: string) => ({ organizationName, role: 'owner', permission: { super_user: true } });
test.beforeEach(async ({ page }) => {
await page.addInitScript(() =>
localStorage.setItem('Studio:PotentiallyAuthenticated', JSON.stringify({ OverallAppSignIn: 'fixture' }))
);
await rememberAppSignIn(page);
await page.route(
'**/*',
route =>
Expand Down
5 changes: 2 additions & 3 deletions e2e/tests/new-org.anon.spec.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
import { expect, test } from '@playwright/test';
import { rememberAppSignIn } from './signInMarkers';

const user = { id: 'new-org-user', email: 'org@example.test', firstname: 'Taylor', lastname: 'River', roles: {} };

test.beforeEach(async ({ page }) => {
await page.addInitScript(() =>
localStorage.setItem('Studio:PotentiallyAuthenticated', JSON.stringify({ OverallAppSignIn: 'fixture' }))
);
await rememberAppSignIn(page);
await page.route('**/*', route =>
['fetch', 'xhr'].includes(route.request().resourceType())
? route.fulfill({ status: 404, json: {} })
Expand Down
5 changes: 2 additions & 3 deletions e2e/tests/organizations.anon.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { expect, test } from '@playwright/test';
import { rememberAppSignIn } from './signInMarkers';

const member = (organizationName: string, role = 'owner') => ({
organizationName,
Expand All @@ -19,9 +20,7 @@ const user = {
};

test.beforeEach(async ({ page }) => {
await page.addInitScript(() =>
localStorage.setItem('Studio:PotentiallyAuthenticated', JSON.stringify({ OverallAppSignIn: 'fixture' }))
);
await rememberAppSignIn(page);
await page.route(
'**/*',
route =>
Expand Down
5 changes: 2 additions & 3 deletions e2e/tests/profile.anon.spec.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
import { expect, test } from '@playwright/test';
import { rememberAppSignIn } from './signInMarkers';

const user = { id: 'profile-user', email: 'profile@example.test', firstname: 'Taylor', lastname: 'River', roles: {} };

test.beforeEach(async ({ page }) => {
await page.addInitScript(() =>
localStorage.setItem('Studio:PotentiallyAuthenticated', JSON.stringify({ OverallAppSignIn: 'fixture' }))
);
await rememberAppSignIn(page);
await page.route(
'**/*',
route =>
Expand Down
5 changes: 2 additions & 3 deletions e2e/tests/sidebar-help.anon.spec.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
import { expect, test } from '@playwright/test';
import { rememberAppSignIn } from './signInMarkers';

test.beforeEach(async ({ page }) => {
await page.addInitScript(() =>
localStorage.setItem('Studio:PotentiallyAuthenticated', JSON.stringify({ OverallAppSignIn: 'fixture' }))
);
await rememberAppSignIn(page);
await page.route(
'**/User/current',
route =>
Expand Down
9 changes: 9 additions & 0 deletions e2e/tests/signInMarkers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import type { Page } from '@playwright/test';

/** Must match `signInMarkerKey(OverallAppSignIn)` in `src/features/auth/store/signInMarkers.ts`, which e2e can't import. */
export const appSignInMarker = 'Studio:PotentiallyAuthenticated:OverallAppSignIn';

/** Marks the app signed in before the page loads, so it checks the mocked session instead of showing sign-in. */
export function rememberAppSignIn(page: Page) {
return page.addInitScript((key) => localStorage.setItem(key, 'fixture'), appSignInMarker);
}
4 changes: 2 additions & 2 deletions src/features/auth/handlers/clearAuthStateLocally.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ import { queryClient } from '@/react-query/queryClient';
/**
* Full LOCAL sign-out: clears all in-memory auth connections/tokens/flags, persisted storage, and BOTH
* React Query caches, WITHOUT posting a logout to CM or instances. None of it may survive into a
* same-tab sign-in as another user, except the explorer's sign-out generation (see
* `authStore.wipeLocalStorageForSignOut`).
* same-tab sign-in as another user, except the explorer's sign-out generation and the sign-in markers' migration flag
* (see `authStore.wipeLocalStorageForSignOut`).
*/
export function clearAuthStateLocally(): void {
runEvery([
Expand Down
10 changes: 6 additions & 4 deletions src/features/auth/handlers/signOutHandlers.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
/** @vitest-environment jsdom */
import { authStore, OverallAppSignIn } from '@/features/auth/store/authStore';
import { seedSignInMarkers, storedSignInMarkers } from '@/features/auth/store/signInMarkersTestHelpers';
import { queryClient } from '@/react-query/queryClient';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { clearAuthStateAfterSignOutElsewhere, clearAuthStateLocally } from './clearAuthStateLocally';
Expand Down Expand Up @@ -88,7 +89,8 @@ describe.each(
expect(authStore.getConnectionById(OverallAppSignIn).user).toBeNull();
expect(authStore.getConnectionById('ins-a').user).toBeNull();
expectCachesEmpty();
expect(localStorageKeys()).toEqual(['Studio:ExplorerAuthEpoch']);
// The migration flag holds no account data, and keeps a previous-release tab's write-back from being migrated.
expect(localStorageKeys().sort()).toEqual(['Studio:ExplorerAuthEpoch', 'Studio:PotentiallyAuthenticatedMigrated']);
expect(sessionStorage.length).toBe(0);
});

Expand Down Expand Up @@ -234,7 +236,7 @@ describe.each(
await signOut();

expectCachesEmpty();
expect(localStorage.length).toBe(0);
expect(localStorageKeys()).toEqual(['Studio:PotentiallyAuthenticatedMigrated']);
expect(sessionStorage.length).toBe(0);
expect(consoleError).toHaveBeenCalledWith(failure);
});
Expand All @@ -244,7 +246,7 @@ describe('clearAuthStateAfterSignOutElsewhere', () => {
it('signs this tab out and wipes both caches and its sessionStorage, but leaves the shared localStorage', () => {
seedSignedInTab();
localStorage.clear();
localStorage.setItem('Studio:PotentiallyAuthenticated', JSON.stringify({ OverallAppSignIn }));
seedSignInMarkers({ OverallAppSignIn });

clearAuthStateAfterSignOutElsewhere();

Expand All @@ -253,7 +255,7 @@ describe('clearAuthStateAfterSignOutElsewhere', () => {
expectCachesEmpty();
expect(sessionStorage.length).toBe(0);
expect(localStorage.length).toBe(1);
expect(localStorage.getItem('Studio:PotentiallyAuthenticated')).toBe(JSON.stringify({ OverallAppSignIn }));
expect(storedSignInMarkers()).toEqual({ OverallAppSignIn });
});

it('still wipes the caches and sessionStorage when the store teardown throws', () => {
Expand Down
Loading
Loading