Skip to content

Commit fdd0641

Browse files
fix(auth): the sign-in page reads the sign-in methods the server has now after sign-out (#30413)
<!-- claude-projects-attribution: {"github_login":"os-bill"} --> _Requested by **Bill** · [project thread](https://claude.ai/code/project/chan_016iMXWSZDzqWRWvWzdNo8wg?thread=cmsg_016iMXWSZDzqWRWvWzdNo8wg2F51sfb77qcGc98qsmi8jZ)_ domain:ui#3 · os-bill · session_01Utp6CbPN5UYMnBaNJPJWfG Refs #30299 Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:管理员在设置页关掉邮箱密码登录、登出后,登录页不再画邮箱密码表单,改为显示服务端现在真正开着的登录方式(如 Google)。 - 为什么改:前端把登录配置缓存到页面刷新为止;设置可以热改后,同一标签页登出看到的是旧配置,表单点了也会被服务端拒绝。 - 风险与回滚:只改 `@object-ui/auth` 的配置缓存:登出或读不到会话时丢弃缓存,登录状态内行为不变;回滚即撤本 PR。 - 先看:`ui/packages/auth/src/createAuthClient.ts` 的 `forgetConfig` 两个调用点(`signOut` 开头、`getSession` 返回空之前)。 - 两分钟验证:`pnpm --dir ui exec vitest run packages/auth/src/__tests__/createAuthClient.test.ts`,预期 36 条全过。 - 需要你做的:无,席位落地;「关闭前确认」需要 settings-manifest 新键,席位另开卡。 ## What changes `createAuthClient().getConfig()` single-flights and caches `/auth/config`. The cache's comment said the config is static per server boot, and it lived for the page's whole life. It is no longer static: the Authentication settings page turns email and password and the Google provider on and off while the server runs, and `/auth/config` answers the new set at once. An administrator who turned email and password off (with Google configured) and signed out inside the console was shown the email and password form from the copy read while signed in, and no Google button. The cache now lives for one signed-in stretch. A new closure helper, `forgetConfig()`, drops it: - at the start of `signOut()` (before the request, so a failed sign-out, which still clears the token, forgets it too); - when `getSession()` finds no session (an expired session reaches the sign-in page without a sign-out). Readers on one page still share one request, and nothing changes while a session lasts. Both login hosts read the config through this client, so the console's own sign-in page is covered without touching it. What this card's two Wanted items get here: - Item 1, first half (the server's refusal text): already holds on `main`, measured below. The floor's refusal arrives as `400 SETTINGS_VALIDATION` with one `details.fields` entry carrying the localized message; the settings page draws it under the "Enable email/password login" row and toasts "1 setting needs attention: Enable email/password login". No change needed. - Item 1, second half (confirm before turning email and password off): NOT in this PR, by the seat's ruling. The settings page is a generic manifest renderer and a toggle specifier has no confirm key; the honest home is a new settings-manifest key, which is spec work, so it goes to its own card. Hard-coding `auth.email_password_enabled` in the renderer was ruled out. - Item 2 (login page draws the form when `emailPassword.enabled` is false): the cause was the stale client cache above, not the dev sign-in chip. `LoginForm` already collapses the form on `enabled === false`; it was handed the old answer. ## Tests - `ui/packages/auth/src/__tests__/createAuthClient.test.ts`, new top-level describe in the existing harness: the cached config is kept while the session lasts (one request), and read again after `signOut()`; and read again after `getSession()` answers no session. - Reverse verification: with the base `createAuthClient.ts` restored over the committed test, both new cases go red (2 failed, 34 passed); restored by `git checkout HEAD`, hash matched HEAD's blob. ## Acceptance notes - Measured live on an own stack: `examples/app-showcase` `objectstack dev --seed-admin --fresh -p 4317` plus the console dev server on 5317, Playwright driving the real pages. Scenario: sign in, Setup, Settings, Authentication; turn email and password off and save (refused, row message shown); add Google client ID and secret in the same draft and save (accepted, `/auth/config` then reports `emailPassword.enabled: false` and a Google provider); sign out from the account menu. - Before (base client): sign-in page draws Email and Password and the Sign In button, no Google button; no `/auth/config` request after sign-out. - After (this branch): one `/auth/config` request after sign-out answering `enabled: false`; the page draws "Continue with Google" and no email field. - Out of scope, for the seat to file: the dev "Sign in as dev admin" chip on the console sign-in page is still offered when email and password are off, and it signs in with email and password, which the server refuses. It lives in the console sign-in page, held by another card. - Out of scope, for the seat to file: the settings spec declares `confirmText` on `action_button` handlers, and the console settings field never reads it, so an action declared with a confirmation runs without one. ## Verification <details><summary>Unit tests (head 777cbe4; the changed test re-run on the merge head b395171: 36 passed)</summary> - `pnpm --dir ui exec vitest run packages/auth/src/__tests__/createAuthClient.test.ts`: Test Files 1 passed, Tests 36 passed. - `pnpm --dir ui exec vitest run --maxWorkers=2 packages/auth/`: Test Files 44 passed, Tests 383 passed. - The 16 sign-in page test files under the console and app-shell that build a real `createAuthClient`: Test Files 16 passed, Tests 103 passed. </details> <details><summary>Derived gates: 35 of 35 exit 0 (head 777cbe4), reconciled by dispatch-gates --ran: 0 NOT-MEASURED, 0 UNRUN</summary> - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 35 commands from the 3 changed paths; each was run with its exit code captured before any pipe; all 35 exit 0, including `check-sdui-manifest`, `check:type-check-debt` and `check:type-check-coverage`. - After merging `origin/main` (no overlap with this diff), re-run on b395171: the changeset gates (`check-adr-0087-registration`, `check-changeset-no-major`, `check-empty-changeset`), `check-closing-keyword-parity`, `check-sdui-manifest`, `check:published-files`, `check:nul-bytes`, `check:type-check-coverage`, `check:type-check-debt`, `check:cross-package-test-inputs`, `check:test-source-alias`: all exit 0. - `pnpm exec turbo run type-check --filter=@object-ui/auth --concurrency=2` (source and test programs): exit 0 on b395171. - The whole-workspace type-check lanes and Test UI run in CI. </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01Utp6CbP8wvhDVypXfydTzD)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 60fe44f commit fdd0641

3 files changed

Lines changed: 83 additions & 7 deletions

File tree

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@object-ui/auth': patch
3+
---
4+
5+
fix(auth): the sign-in page reads the sign-in methods the server has now, not the ones it had when the tab was opened (#30299)
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
The auth client kept the first `/auth/config` answer for the life of the page. An administrator who turned email and password off on the Authentication settings page (with another way in configured) and then signed out was shown the email and password form on the sign-in page, from the copy read while signed in, although the server refuses that form. The client now forgets the cached config whenever the tab is left without a session: on sign-out, and when a session read finds none (an expired session reaches the sign-in page without a sign-out). Readers on one page still share one request, and nothing changes while a session lasts.

‎ui/packages/auth/src/__tests__/createAuthClient.test.ts‎

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -558,6 +558,52 @@ describe('createAuthClient — getConfig single-flight / cache / retry (#2625)',
558558
});
559559
});
560560

561+
describe('createAuthClient — getConfig is read fresh once the tab has no session (objectstack#30299)', () => {
562+
// The settings page turns sign-in doors on and off while the server runs,
563+
// so the copy cached during a signed-in stretch is not the sign-in page's.
564+
function configServer(emailPasswordEnabled: { value: boolean }) {
565+
return createMockFetch({
566+
'/sign-out': { body: { success: true } },
567+
'/get-session': { body: null },
568+
get '/config'() {
569+
return { body: { data: { emailPassword: { enabled: emailPasswordEnabled.value } } } };
570+
},
571+
});
572+
}
573+
const configReads = (calls: Array<{ url: string }>) => calls.filter((c) => c.url.includes('/config'));
574+
575+
it('keeps the cached config while the session lasts, and reads it again after sign-out', async () => {
576+
const enabled = { value: true };
577+
const { mockFn, calls } = configServer(enabled);
578+
const client = createAuthClient({ baseURL: 'http://localhost/api/auth', fetchFn: mockFn });
579+
580+
expect((await client.getConfig()).emailPassword?.enabled).toBe(true);
581+
// An administrator turns email and password off from the settings page.
582+
enabled.value = false;
583+
expect((await client.getConfig()).emailPassword?.enabled).toBe(true);
584+
expect(configReads(calls)).toHaveLength(1);
585+
586+
await client.signOut();
587+
588+
expect((await client.getConfig()).emailPassword?.enabled).toBe(false);
589+
expect(configReads(calls)).toHaveLength(2);
590+
});
591+
592+
it('reads it again after a session read finds no session (an expired session never signs out)', async () => {
593+
const enabled = { value: true };
594+
const { mockFn, calls } = configServer(enabled);
595+
const client = createAuthClient({ baseURL: 'http://localhost/api/auth', fetchFn: mockFn });
596+
597+
expect((await client.getConfig()).emailPassword?.enabled).toBe(true);
598+
enabled.value = false;
599+
600+
expect(await client.getSession()).toBeNull();
601+
602+
expect((await client.getConfig()).emailPassword?.enabled).toBe(false);
603+
expect(configReads(calls)).toHaveLength(2);
604+
});
605+
});
606+
561607
describe('createAuthClient — signInWithProvider watchdog (#2626)', () => {
562608
it('rejects with a timeout error when the sign-in request never returns', async () => {
563609
vi.useFakeTimers();

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

Lines changed: 27 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -491,16 +491,27 @@ export function createAuthClient(config: AuthClientConfig): AuthClient {
491491
}
492492
}
493493

494-
// Auth config is static per server boot: single-flight + cache the success
495-
// so the login page's multiple consumers (LoginForm, SocialSignInButtons,
496-
// RegisterForm) share ONE request instead of three. Failures RETRY with a
497-
// short backoff before rejecting — a first-load failure used to render the
498-
// page without its SSO buttons, which strands SSO-only users on a password
499-
// wall they have no password for (#2625). A final failure clears the cache
500-
// so the next caller starts a fresh cycle.
494+
// Single-flight + cache the success so the login page's multiple consumers
495+
// (LoginForm, SocialSignInButtons, RegisterForm) share ONE request instead
496+
// of three. Failures RETRY with a short backoff before rejecting — a
497+
// first-load failure used to render the page without its SSO buttons, which
498+
// strands SSO-only users on a password wall they have no password for
499+
// (#2625). A final failure clears the cache so the next caller starts a
500+
// fresh cycle.
501+
//
502+
// The config is NOT static per server boot: an administrator changes the
503+
// sign-in doors from the settings page (email and password, the Google
504+
// provider) and the server answers the new set at once. So the cache lives
505+
// for one signed-in stretch only — `forgetConfig()` runs whenever this tab
506+
// is left without a session (sign-out, or a session read that finds none),
507+
// and the sign-in page that follows reads the doors the server has now
508+
// instead of the ones it had when the tab was opened.
501509
const CONFIG_RETRY_DELAYS_MS = [500, 1500, 3500];
502510
const CONFIG_ATTEMPT_TIMEOUT_MS = 8_000;
503511
let configPromise: Promise<AuthPublicConfig> | null = null;
512+
const forgetConfig = () => {
513+
configPromise = null;
514+
};
504515

505516
async function loadConfigWithRetry(): Promise<AuthPublicConfig> {
506517
let lastError: unknown;
@@ -649,6 +660,11 @@ export function createAuthClient(config: AuthClientConfig): AuthClient {
649660
// `providerLogoutURL`); without a provider the answer is
650661
// `{ success: true }` and the caller's in-app route to the sign-in page
651662
// stays as it was.
663+
//
664+
// The sign-in page comes next, so it must read the doors the server
665+
// has now; forgotten before the request, so a failed sign-out (which
666+
// still leaves this tab without its token) forgets it too.
667+
forgetConfig();
652668
const { data, error } = await betterAuth.signOut({ callbackURL: resolveSpaRoute('/login') });
653669
TokenStorage.clear();
654670
if (error) {
@@ -721,6 +737,10 @@ export function createAuthClient(config: AuthClientConfig): AuthClient {
721737
}
722738
} catch { /* network hiccup — treat as signed out, keep the token */ }
723739
}
740+
// No session: whatever reads the config next is the sign-in page (a
741+
// session that expired lands there without a sign-out), so it reads it
742+
// fresh rather than the copy cached while signed in.
743+
forgetConfig();
724744
return null;
725745
},
726746

0 commit comments

Comments
 (0)