Skip to content

Commit f63ad0b

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/project-thread-q572zu
2 parents e205cf3 + c322421 commit f63ad0b

6 files changed

Lines changed: 153 additions & 19 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@object-ui/auth': patch
3+
'@object-ui/console': patch
4+
---
5+
6+
fix(console,auth): after an explicit sign-out, whoever signs in next lands on their own home, not on the page the previous person had open
7+
8+
Clause-②: yes (widening)
9+
Metadata-protocol: none
10+
11+
- **`@object-ui/auth`: `AuthContextValue.sessionEndedBySignOut`**, `true` from a `signOut()` until a session is established again in the tab, whoever it belongs to; `false` on load, while signed in, and after a session that ended any other way (expired, revoked, signed out in another tab). It is a new required member of the exported `AuthContextValue` type: code that builds its own value of that type (a test double, a custom provider) must add `sessionEndedBySignOut: false` (or its own reading) to type-check.
12+
- **`@object-ui/console`: `LoginRedirect`** sends the visitor to `/login` with no `?redirect=` after an explicit sign-out. A new member signing in after the administrator signed out of a Setup page used to land on that page and read "you do not have access to this app". A session that expires or is revoked under the person still keeps the path (list state stripped as before), so that person signs back in where they were.

‎ui/apps/console/src/components/ProtectedRoute.signOutListState-29174.test.tsx‎

Lines changed: 99 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,11 @@
1818
* `LoginRedirect`), and the real Console `LoginPage` with its post-login
1919
* hand-off. Only the shell's provider stack is a pass-through.
2020
*
21-
* - A signs out of a list carrying every kind of list state; the login page's
22-
* URL carries none of it, and B lands on the same path with the other
23-
* params kept (the card's reproduction, steps 4 to 7);
21+
* - A's session expires on a list carrying every kind of list state; the
22+
* login page's URL carries none of it, and B lands on the same path with
23+
* the other params kept (the card's reproduction, steps 4 to 7, with the
24+
* session ending by expiry: an explicit sign-out now carries no return link
25+
* at all, pinned in the objectstack#30125 block below);
2426
* - an invitation redirect still lands on the invitation;
2527
* - a signed-out arrival on a deep link with no list state keeps it byte for
2628
* byte;
@@ -34,10 +36,10 @@
3436

3537
import '@testing-library/jest-dom/vitest';
3638
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
37-
import { render, screen, waitFor, cleanup } from '@testing-library/react';
39+
import { render, screen, waitFor, cleanup, act } from '@testing-library/react';
3840
import userEvent from '@testing-library/user-event';
39-
import { MemoryRouter, Routes, Route, useLocation } from 'react-router-dom';
40-
import type { ReactNode } from 'react';
41+
import { MemoryRouter, Routes, Route, useLocation, useNavigate } from 'react-router-dom';
42+
import { useEffect, type ReactNode } from 'react';
4143
import { AuthProvider, useAuth } from '@object-ui/auth';
4244
import type { AuthClient } from '@object-ui/auth';
4345

@@ -100,24 +102,43 @@ function createClient(): AuthClient {
100102

101103
/** Every location the router settles on, oldest first. */
102104
const seen: string[] = [];
105+
/** The router's own navigate, for an in-app move between sign-ins. */
106+
const navigateTo: { current: ((to: string) => void) | null } = { current: null };
103107
function LocationProbe() {
104108
const location = useLocation();
109+
const navigate = useNavigate();
110+
useEffect(() => {
111+
navigateTo.current = navigate;
112+
}, [navigate]);
105113
seen.push(location.pathname + location.search);
106114
return null;
107115
}
108116

109-
/** The list page, reduced to the account menu's sign-out. */
117+
/**
118+
* The list page, reduced to the account menu's sign-out and a session
119+
* re-resolution: the provider's own loader, which is how a session that
120+
* expired or was revoked under the page reaches it.
121+
*/
110122
function SignedInList() {
111-
const { user, signOut } = useAuth();
123+
const { user, signOut, refreshSession } = useAuth();
112124
return (
113125
<div data-testid="list-page" data-user={user?.id ?? ''}>
114126
<button type="button" onClick={() => void signOut()}>
115127
Sign out
116128
</button>
129+
<button type="button" onClick={() => void refreshSession()}>
130+
Re-check session
131+
</button>
117132
</div>
118133
);
119134
}
120135

136+
/** The server ends the session on its own (expiry, revocation), and the page learns it. */
137+
async function expireSession() {
138+
serverUser = null;
139+
await userEvent.setup().click(screen.getByRole('button', { name: 'Re-check session' }));
140+
}
141+
121142
function renderConsole(entry: string, client: AuthClient) {
122143
return render(
123144
<AuthProvider authUrl="/api/v1/auth" client={client}>
@@ -176,7 +197,7 @@ afterEach(() => {
176197
});
177198

178199
describe('signing out leaves the list state behind (objectstack#29174)', () => {
179-
it('A signs out of a filtered, searched, sorted list; B signs in and lands on the path with none of it', async () => {
200+
it("A's session expires on a filtered, searched, sorted list; B signs in and lands on the path with none of it", async () => {
180201
serverUser = USER_A;
181202
const client = createClient();
182203
renderConsole(A_LIST_URL, client);
@@ -185,8 +206,8 @@ describe('signing out leaves the list state behind (objectstack#29174)', () => {
185206
expect((await screen.findByTestId('list-page')).dataset.user).toBe(USER_A.id);
186207
expect(seen[seen.length - 1]).toBe(A_LIST_URL);
187208

188-
await userEvent.setup().click(screen.getByRole('button', { name: 'Sign out' }));
189-
expect(client.signOut).toHaveBeenCalledTimes(1);
209+
await expireSession();
210+
expect(client.signOut).not.toHaveBeenCalled();
190211

191212
// The login page's own URL carries none of it: not the params, not a value.
192213
const { url, redirect } = await loginRedirect();
@@ -236,3 +257,70 @@ describe('signing out leaves the list state behind (objectstack#29174)', () => {
236257
expect(redirect).toBe(`${LIST_PATH}?recordId=r1`);
237258
});
238259
});
260+
261+
/**
262+
* objectstack#30125 — after a sign-out, the next person lands on the previous
263+
* person's page. A new member's first screen was "you do not have access to
264+
* this app" for the Setup page the administrator had open.
265+
*
266+
* An explicit sign-out sends the visitor to `/login` with no return link, so
267+
* whoever signs in next lands on their own home. A session that ends under the
268+
* person (expired, revoked) still keeps the path, so that person signs back in
269+
* where they were. Same real composition as the block above.
270+
*/
271+
describe('an explicit sign-out leaves no return link (objectstack#30125)', () => {
272+
const SETUP_PAGE = '/apps/com.objectstack.setup/sys_user/view/all?recordId=u1';
273+
274+
it('A signs out of a Setup page; B signs in and lands on home, not on that page', async () => {
275+
serverUser = USER_A;
276+
const client = createClient();
277+
renderConsole(SETUP_PAGE, client);
278+
expect((await screen.findByTestId('list-page')).dataset.user).toBe(USER_A.id);
279+
280+
await userEvent.setup().click(screen.getByRole('button', { name: 'Sign out' }));
281+
expect(client.signOut).toHaveBeenCalledTimes(1);
282+
283+
const { url, redirect } = await loginRedirect();
284+
expect(url).toBe('/login');
285+
expect(redirect).toBeNull();
286+
287+
await signInAs(USER_B.email);
288+
expect(await landing()).toBe(withConsoleBase('/'));
289+
});
290+
291+
it("A's session expires on the same page; A signs back in and lands on it", async () => {
292+
serverUser = USER_A;
293+
const client = createClient();
294+
renderConsole(SETUP_PAGE, client);
295+
expect((await screen.findByTestId('list-page')).dataset.user).toBe(USER_A.id);
296+
297+
await expireSession();
298+
299+
const { url, redirect } = await loginRedirect();
300+
expect(url).toBe(`/login?redirect=${encodeURIComponent(SETUP_PAGE)}`);
301+
expect(redirect).toBe(SETUP_PAGE);
302+
303+
await signInAs(USER_A.email);
304+
expect(await landing()).toBe(withConsoleBase(SETUP_PAGE));
305+
});
306+
307+
it('the sign-out is forgotten once someone signs in: a later expiry keeps the path again', async () => {
308+
serverUser = USER_A;
309+
const client = createClient();
310+
renderConsole(SETUP_PAGE, client);
311+
expect((await screen.findByTestId('list-page')).dataset.user).toBe(USER_A.id);
312+
313+
// A signs out; B signs in on the same tab, without a page load.
314+
await userEvent.setup().click(screen.getByRole('button', { name: 'Sign out' }));
315+
expect((await loginRedirect()).redirect).toBeNull();
316+
await signInAs(USER_B.email);
317+
await landing();
318+
319+
// B opens the page in-app, and B's session then expires under it.
320+
act(() => navigateTo.current!(SETUP_PAGE));
321+
expect((await screen.findByTestId('list-page')).dataset.user).toBe(USER_B.id);
322+
await expireSession();
323+
324+
expect((await loginRedirect()).redirect).toBe(SETUP_PAGE);
325+
});
326+
});

‎ui/apps/console/src/components/ProtectedRoute.tsx‎

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212

1313
import type { ReactNode } from 'react';
1414
import { useLocation } from 'react-router-dom';
15-
import { AuthGuard } from '@object-ui/auth';
15+
import { AuthGuard, useAuth } from '@object-ui/auth';
1616
import {
1717
ConnectedShell,
1818
RequireOrganization,
@@ -29,15 +29,25 @@ import {
2929
* every embedded deployment, so a window-derived path would carry the `/_console`
3030
* mount into a value that `LoginPage` then re-prefixes with it (objectui#4168).
3131
*
32-
* It never carries the list's `uf_*` state (objectstack#29174). Whoever signs in
33-
* on that page may not be the person whose session just ended, and a filter
34-
* value can be sensitive; this guard cannot tell a sign-out from an expiry, so
35-
* it drops the state for both. The path and every other param are kept, byte
36-
* for byte when there was no list state, and the list then opens on the
37-
* signing-in user's own stored state or the view's default.
32+
* After an explicit sign-out it carries no return link at all
33+
* (objectstack#30125): the person left, so whoever signs in next is nobody in
34+
* particular and lands on their own home, not on the page the previous person
35+
* had open (a Setup page a new member may not open, say). `AuthProvider` tells
36+
* the two apart with `sessionEndedBySignOut`.
37+
*
38+
* Otherwise (the session expired or was revoked under the person, or a
39+
* signed-out visitor followed a deep link) the path and its params are kept, so
40+
* the person signs back in where they were. Even then it never carries the
41+
* list's `uf_*` state (objectstack#29174): whoever signs in after an expiry may
42+
* still be someone else, and a filter value can be sensitive. The
43+
* path and every other param are kept, byte for byte when there was no list
44+
* state, and the list then opens on the signing-in user's own stored state or
45+
* the view's default.
3846
*/
3947
export function LoginRedirect() {
4048
const location = useLocation();
49+
const { sessionEndedBySignOut } = useAuth();
50+
if (sessionEndedBySignOut) return <RedirectWithSplash to="/login" replace />;
4151
const params = new URLSearchParams(location.search);
4252
const kept = withoutListStateParams(params).toString();
4353
const returnSearch = kept === params.toString() ? location.search : kept ? `?${kept}` : '';

‎ui/packages/auth/src/AuthContext.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,15 @@ export interface AuthContextValue {
1616
session: AuthClientSession | null;
1717
/** Whether the user is authenticated */
1818
isAuthenticated: boolean;
19+
/**
20+
* objectstack#30125 — `true` when this tab's last session was ended by
21+
* `signOut()` and no session has been established since; `false` on load,
22+
* while signed in, and after a session that ended any other way (expired,
23+
* revoked, signed out in another tab). Lets a sign-in redirect tell "the
24+
* person left", where whoever signs in next is nobody in particular, from
25+
* "the session lapsed under the person", who comes back to the same page.
26+
*/
27+
sessionEndedBySignOut: boolean;
1928
/**
2029
* Whether real authentication is enabled in this environment.
2130
* - `true`: real auth backend is in use; sign-in / sign-out are meaningful.

‎ui/packages/auth/src/AuthProvider.tsx‎

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,13 @@ export function AuthProvider({
191191
// finds anything else drops it for good, so every later run loads as before.
192192
const bootSessionFor = useRef<string | null>(bootSession ? authUrl : null);
193193
const [error, setError] = useState<Error | null>(null);
194+
// objectstack#30125 — the session in this tab was ended by `signOut()`, and
195+
// nobody has signed in since. Raised in the same batch that clears `user`, so
196+
// the first render without a session already reads it; lowered as soon as a
197+
// session exists again (below), whoever it belongs to. A session
198+
// that ends any other way (expired, revoked, signed out in another tab)
199+
// leaves it `false`.
200+
const [sessionEndedBySignOut, setSessionEndedBySignOut] = useState(false);
194201
const [remediationRequired, setRemediationRequired] = useState<{ code: string; message: string } | null>(null);
195202
// objectstack#29105 — a password sign-in waiting for its second factor.
196203
const [twoFactorChallenge, setTwoFactorChallenge] = useState<TwoFactorChallenge | null>(null);
@@ -215,6 +222,11 @@ export function AuthProvider({
215222
? user !== null && session !== null
216223
: true;
217224

225+
// Any session, a new sign-in or a re-resolved one, ends "signed out".
226+
// Adjusted during render (React's "storing information from previous
227+
// renders"), so no render ever pairs a session with a raised flag.
228+
if (sessionEndedBySignOut && user !== null) setSessionEndedBySignOut(false);
229+
218230
// True while `loadSession` is awaiting the server. Read by the rotation
219231
// subscription below: a rotation the server performs INSIDE our own
220232
// `getSession` is already reflected in the answer we are about to apply, and
@@ -482,6 +494,7 @@ export function AuthProvider({
482494
await client.signOut();
483495
setUser(null);
484496
setSession(null);
497+
setSessionEndedBySignOut(true);
485498
setError(null);
486499
setTwoFactorChallenge(null);
487500
// The organization block is the previous principal's data too: the list
@@ -1141,6 +1154,7 @@ export function AuthProvider({
11411154
user,
11421155
session,
11431156
isAuthenticated,
1157+
sessionEndedBySignOut,
11441158
isAuthEnabled,
11451159
isLoading,
11461160
error,
@@ -1196,7 +1210,7 @@ export function AuthProvider({
11961210
listUserInvitations,
11971211
}),
11981212
[
1199-
user, session, isAuthenticated, isAuthEnabled, isLoading, error, isPreviewMode, previewMode,
1213+
user, session, isAuthenticated, sessionEndedBySignOut, isAuthEnabled, isLoading, error, isPreviewMode, previewMode,
12001214
signIn, twoFactorChallenge, verifySignInTwoFactor, cancelTwoFactorChallenge, signUp, signOut, refreshSession, updateUser, forgotPassword, sendVerificationEmail, resetPassword, changePassword, setInitialPassword, hasLocalPassword, getAuthConfig, signInWithProvider,
12011215
sendPhoneOtp, signInWithPhoneOtp, signInWithPhonePassword, requestPhonePasswordReset, resetPasswordWithPhoneOtp,
12021216
remediationRequired, enrollTotp, verifyTotp,

‎ui/packages/auth/src/useAuth.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ export function useAuth(): AuthContextValue {
3131
user: null,
3232
session: null,
3333
isAuthenticated: false,
34+
sessionEndedBySignOut: false,
3435
isAuthEnabled: false,
3536
isLoading: false,
3637
error: null,

0 commit comments

Comments
 (0)