a deleted or blocked account loses its session on the next request
Reported from two browser windows: an account deleted from the dashboard survived a page refresh in the other one. Two independent halves. Server: userMiddleware looked the account up, then read the result as `dbUser?.passwordChangedAt` — so a DELETED account fell through the optional chain and the request proceeded on a token that is still cryptographically valid, for up to the full 30 days. `status` was the same hole from the other direction: signin refuses anything that is not Active, but nothing rechecked it afterwards, so marking someone Blocked did not end the session they already had, which is exactly when you would be doing it. Now the account must exist and be Active on every request. Client: nothing reacted to a 401 at all. onError fed the bug-report form and stopped there, so the window kept rendering off cached React Query data. A 401 now clears every storage key createClient reads and returns to the sign-in screen. /auth/ is exempt because a wrong password is also a 401 and reloading the form would look like a crash. window.officerBearerToken was declared non-optional, which made "there is no token" unspeakable. It has always been one of five sources, any of which may be absent. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -165,6 +165,35 @@ export class ApiError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A 401 on an authenticated call means the token is no longer good — deleted account, blocked account,
|
||||
* changed password, explicit signout elsewhere. Clear it and get back to the sign-in screen.
|
||||
*
|
||||
* Until this existed, nothing in the client reacted to a 401 at all: `onError` fed the bug-report form and
|
||||
* that was the end of it. So an account deleted from the dashboard kept its window rendering happily off
|
||||
* cached React Query data, and a refresh looked like a live session. Observed 2026-08-11.
|
||||
*
|
||||
* `/auth/…` is exempt, and that exemption is load-bearing rather than tidy: a wrong password IS a 401, and
|
||||
* reloading the sign-in page in response would wipe the form and look like a crash.
|
||||
*
|
||||
* `location.replace` rather than a router navigation: this module has no router, and after revocation the
|
||||
* cleanest state is a fresh document with no stale query cache in it.
|
||||
*/
|
||||
const handleRevokedSession = (url: string) => {
|
||||
if (url.includes('/auth/')) return;
|
||||
|
||||
theToken = null;
|
||||
// Every key `createClient` reads, or the next load signs straight back in with the dead token.
|
||||
for (const key of ['PERTENTO_EDITOR_AUTH_TOKEN', 'BEARER_TOKEN']) {
|
||||
localStorage.removeItem(key);
|
||||
sessionStorage.removeItem(key);
|
||||
}
|
||||
window.officerBearerToken = undefined;
|
||||
|
||||
// Guard against a reload loop if the sign-in screen itself ever 401s on a non-/auth call.
|
||||
if (!window.location.pathname.startsWith('/signin')) window.location.replace('/');
|
||||
};
|
||||
|
||||
const validateResponse = async (res: Response) => {
|
||||
if (res.status >= 400) {
|
||||
const { onError } = useClient.config;
|
||||
@@ -172,6 +201,7 @@ const validateResponse = async (res: Response) => {
|
||||
if (onError) {
|
||||
onError({ status: res.status, message });
|
||||
}
|
||||
if (res.status === 401) handleRevokedSession(res.url);
|
||||
throw new ApiError(res.status, message);
|
||||
}
|
||||
};
|
||||
|
||||
Vendored
+5
-1
@@ -16,7 +16,11 @@ declare global {
|
||||
};
|
||||
|
||||
interface Window {
|
||||
officerBearerToken: string;
|
||||
/**
|
||||
* Optional, and honestly so: `createClient` reads this first but falls through to four other sources,
|
||||
* and `handleRevokedSession` clears it. Declaring it non-optional made "there is no token" unspeakable.
|
||||
*/
|
||||
officerBearerToken?: string;
|
||||
__officerRuntimeBridge?: boolean;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user