From 06c559e7e9077b1c17c66da22fcf824a395019df Mon Sep 17 00:00:00 2001 From: chennan Date: Fri, 29 May 2026 17:55:53 +0800 Subject: [PATCH 1/7] remove jwt-based username parsing from sign-in flow --- spx-gui/package-lock.json | 1 - spx-gui/package.json | 1 - spx-gui/src/pages/sign-in/token.vue | 25 +---------- spx-gui/src/stores/user/signed-in.ts | 63 +++++++++++++--------------- 4 files changed, 30 insertions(+), 60 deletions(-) diff --git a/spx-gui/package-lock.json b/spx-gui/package-lock.json index 8af7064aa0..98f3e09be4 100644 --- a/spx-gui/package-lock.json +++ b/spx-gui/package-lock.json @@ -44,7 +44,6 @@ "hast": "^1.0.0", "hast-util-raw": "^9.1.0", "hast-util-sanitize": "^5.0.2", - "jwt-decode": "^4.0.0", "konva": "^9.3.1", "localforage": "^1.10.0", "lodash": "^4.17.21", diff --git a/spx-gui/package.json b/spx-gui/package.json index 898991f330..3c1f080953 100644 --- a/spx-gui/package.json +++ b/spx-gui/package.json @@ -51,7 +51,6 @@ "hast": "^1.0.0", "hast-util-raw": "^9.1.0", "hast-util-sanitize": "^5.0.2", - "jwt-decode": "^4.0.0", "konva": "^9.3.1", "localforage": "^1.10.0", "lodash": "^4.17.21", diff --git a/spx-gui/src/pages/sign-in/token.vue b/spx-gui/src/pages/sign-in/token.vue index 357879f0f4..58c06f13b5 100644 --- a/spx-gui/src/pages/sign-in/token.vue +++ b/spx-gui/src/pages/sign-in/token.vue @@ -25,16 +25,14 @@ html-type="submit" :loading="handleSubmit.isLoading.value" > - {{ buttonText }} + {{ $t({ en: 'Sign in', zh: '登录' }) }} diff --git a/spx-gui/src/router.ts b/spx-gui/src/router.ts index a15d8af600..0e57b31b67 100644 --- a/spx-gui/src/router.ts +++ b/spx-gui/src/router.ts @@ -2,7 +2,7 @@ import type { App } from 'vue' import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router' import { searchKeywordQueryParamName } from '@/pages/community/search.vue' import type { ExploreOrder } from './apis/project' -import { initiateSignIn, isSignedIn, getUnresolvedSignedInUsername } from './stores/user' +import { initiateSignIn, isSignedIn } from './stores/user' export function getProjectEditorRoute(ownerName: string, projectName: string, publish = false) { ownerName = encodeURIComponent(ownerName) @@ -11,14 +11,8 @@ export function getProjectEditorRoute(ownerName: string, projectName: string, pu } export function getOwnProjectEditorRoute(projectName: string, publish = false) { - // TODO: Remove this helper after splitting "open my project editor" into two layers: - // - a pure self-entry route builder like `/editor/:projectName` - // - async resolution of the canonical signed-in user at that route boundary - // Then navigate to `/editor/:owner/:project` with backend-confirmed signed-in user data, - // instead of deriving owner name from unresolved local auth state synchronously. - const username = getUnresolvedSignedInUsername() - if (username == null) throw new Error('User not signed in') - return getProjectEditorRoute(username, projectName, publish) + projectName = encodeURIComponent(projectName) + return publish ? `/editor/${projectName}?publish` : `/editor/${projectName}` } export function getProjectPageRoute(owner: string, name: string) { @@ -139,22 +133,8 @@ const routes: Array = [ }, { path: '/editor/:projectNameInput', - redirect(to) { - const { projectNameInput } = to.params - // TODO: Replace this synchronous redirect with an async entry boundary (for example `beforeEnter`) that: - // - checks/initiates sign-in - // - awaits canonical signed-in user data - // - redirects to `/editor/:owner/:project` - // That would let router stop depending on unresolved local username hints here. - const username = getUnresolvedSignedInUsername() - // Route with `redirect` will not trigger the global `beforeEach` guard, - // so we need to check sign-in status here. - if (username == null) { - initiateSignIn() - throw new Error('User not signed in') // prevent router from redirecting - } - return getProjectEditorRoute(username, projectNameInput as string) - } + component: () => import('@/pages/editor/entry.vue'), + props: true }, { path: '/sign-in/callback', diff --git a/spx-gui/src/stores/user/signed-in.test.ts b/spx-gui/src/stores/user/signed-in.test.ts index 5beeac19d0..25f33f5f04 100644 --- a/spx-gui/src/stores/user/signed-in.test.ts +++ b/spx-gui/src/stores/user/signed-in.test.ts @@ -68,22 +68,6 @@ describe('signed-in user query key scope', () => { }) it('should change the signed-in user query key across sign-in and sign-out transitions', async () => { - getSignedInUser.mockResolvedValue({ - id: 'user-id', - createdAt: '2025-01-01T00:00:00Z', - updatedAt: '2025-01-01T00:00:00Z', - username: 'alice', - displayName: 'Alice', - avatar: '', - description: '', - plan: 'free', - capabilities: { - canManageAssets: false, - canManageCourses: false, - canUsePremiumLLM: false - } - }) - const userStore = await import('./signed-in') withSetup(() => userStore.useSignedInUser()) @@ -102,27 +86,25 @@ describe('signed-in user query key scope', () => { expect(queryKey.value).toEqual(['signed-in-user', 2]) }) - it('should keep auth state when syncing username hint fails transiently during sign-in', async () => { + it('should not eagerly validate token by fetching signed-in user during sign-in', async () => { const userStore = await import('./signed-in') - getSignedInUser.mockRejectedValueOnce(new Error('network error')) - await expect(userStore.signInWithAccessToken('token-a')).resolves.toBeUndefined() expect(userStore.isSignedIn()).toBe(true) - expect(userStore.getUnresolvedSignedInUsername()).toBe(null) + expect(getSignedInUser).not.toHaveBeenCalled() }) - it('should still fail sign-in and clear auth state on unauthorized username sync', async () => { + it('should not bump auth-session scope when resolving access token for a guest session', async () => { const userStore = await import('./signed-in') - const { ApiException, ApiExceptionCode } = await import('@/apis/common/exception') - const unauthorizedError = new ApiException(ApiExceptionCode.errorUnauthorized, 'Unauthorized', { - req: new Request('https://api.example.com/user', { method: 'GET' }) - }) - getSignedInUser.mockRejectedValueOnce(unauthorizedError) + withSetup(() => userStore.useSignedInUser()) + let queryKey = useVueQueryMock.mock.lastCall?.[0]?.queryKey + expect(queryKey.value).toEqual(['signed-in-user', 0]) + + await expect(userStore.ensureAccessToken()).resolves.toBeNull() - await expect(userStore.signInWithAccessToken('token-a')).rejects.toBe(unauthorizedError) - expect(userStore.isSignedIn()).toBe(false) - expect(userStore.getUnresolvedSignedInUsername()).toBe(null) + withSetup(() => userStore.useSignedInUser()) + queryKey = useVueQueryMock.mock.lastCall?.[0]?.queryKey + expect(queryKey.value).toEqual(['signed-in-user', 0]) }) }) diff --git a/spx-gui/src/stores/user/signed-in.ts b/spx-gui/src/stores/user/signed-in.ts index cb2afc3a59..c3e76c360a 100644 --- a/spx-gui/src/stores/user/signed-in.ts +++ b/spx-gui/src/stores/user/signed-in.ts @@ -1,7 +1,6 @@ import { reactive, watchEffect, computed, ref } from 'vue' import Sdk from 'casdoor-js-sdk' import { casdoorConfig } from '@/utils/env' -import { ApiException, ApiExceptionCode } from '@/apis/common/exception' import { useQueryWithCache, useQueryCache, useQuery, composeQuery } from '@/utils/query' import { useAction } from '@/utils/exception' import * as apis from '@/apis/user' @@ -19,24 +18,16 @@ const userStateStorageKey = 'spx-user' const userState = reactive({ accessToken: null as string | null, accessTokenExpiresAt: null as number | null, - refreshToken: null as string | null, - /** - * Username cached from canonical signed-in user data, or null if not available. - * - * This value is only a local synchronous hint. It must not be treated as canonical - * backend-confirmed identity for ownership, permissions, or other behavior-sensitive logic. - * - * It cannot be removed yet because some synchronous boundaries still need a session-scoped - * username before canonical signed-in user data can be awaited, such as temporary router - * self-route derivation and user-scoped storage fallback. - */ - username: null as string | null + refreshToken: null as string | null }) export function initUserState() { const stored = localStorage.getItem(userStateStorageKey) if (stored != null) { - Object.assign(userState, JSON.parse(stored)) + const parsed = JSON.parse(stored) + userState.accessToken = parsed.accessToken ?? null + userState.accessTokenExpiresAt = parsed.accessTokenExpiresAt ?? null + userState.refreshToken = parsed.refreshToken ?? null } watchEffect(() => localStorage.setItem(userStateStorageKey, JSON.stringify(userState))) } @@ -54,53 +45,17 @@ function handleTokenResponse(resp: TokenResponse) { } /** - * Local scope for the signed-in user query key and other async signed-in-user sync writes. + * Local auth-session scope for signed-in-user queries and derived async reads. * - * This is bumped whenever local auth state switches to a new sign-in session boundary, such as - * sign-in or sign-out, so stale async results from an older local session cannot write back into - * the current signed-in session state. + * This is bumped whenever local auth state crosses a session boundary, such as sign-in or sign-out, + * so cached data and in-flight async results from an older local auth session are not consumed in the + * current session. */ -const signedInUserQueryScope = ref(0) +const authSessionScope = ref(0) -function bumpSignedInUserQueryScope() { - signedInUserQueryScope.value++ - return signedInUserQueryScope.value -} - -function syncCachedSignedInUsername(username: string | null, scope: number = signedInUserQueryScope.value) { - if (scope !== signedInUserQueryScope.value) return - userState.username = username -} - -/** - * Refresh the local cached username from canonical signed-in user data. - * - * This exists only to keep temporary synchronous username consumers working after sign-in or - * manual token changes. The fetched user data is canonical; the cached `userState.username` - * written here is not. - * - * If canonical signed-in user loading fails with an auth error, local auth state is cleared and - * the error is re-thrown. For transient failures, auth state is kept, the local username hint is - * cleared, and the error is swallowed. - */ -async function syncSignedInUsername(scope: number = signedInUserQueryScope.value) { - if (userState.accessToken == null) { - syncCachedSignedInUsername(null, scope) - return null - } - try { - const user = await apis.getSignedInUser() - syncCachedSignedInUsername(user.username, scope) - return user - } catch (error) { - syncCachedSignedInUsername(null, scope) - if (error instanceof ApiException && error.code === ApiExceptionCode.errorUnauthorized) { - signOut() - throw error - } - console.error('failed to sync signed-in username hint', error) - return null - } +function bumpAuthSessionScope() { + authSessionScope.value++ + return authSessionScope.value } export function initiateSignIn( @@ -117,24 +72,29 @@ export function initiateSignIn( export async function completeSignIn() { const resp = await casdoorSdk.exchangeForAccessToken() handleTokenResponse(resp) - const scope = bumpSignedInUserQueryScope() - await syncSignedInUsername(scope) + bumpAuthSessionScope() } export async function signInWithAccessToken(accessToken: string) { userState.accessToken = accessToken userState.accessTokenExpiresAt = null userState.refreshToken = null - const scope = bumpSignedInUserQueryScope() - await syncSignedInUsername(scope) + bumpAuthSessionScope() +} + +function hasLocalAuthState() { + return userState.accessToken != null || userState.accessTokenExpiresAt != null || userState.refreshToken != null } export function signOut() { - const scope = bumpSignedInUserQueryScope() + // `ensureAccessToken()` is also used by guest/public API requests. If we are already effectively + // signed out, returning early avoids bumping `authSessionScope` for those requests, which would + // otherwise retrigger signed-in-state consumers and can cascade into startup update loops. + if (!hasLocalAuthState()) return + bumpAuthSessionScope() userState.accessToken = null userState.accessTokenExpiresAt = null userState.refreshToken = null - syncCachedSignedInUsername(null, scope) } const tokenExpiryDelta = 60 * 1000 // 1 minute in milliseconds @@ -145,7 +105,6 @@ export async function ensureAccessToken(): Promise { if (tokenRefreshPromise != null) return tokenRefreshPromise if (userState.refreshToken == null) { - signOut() return null } @@ -182,23 +141,8 @@ export function isSignedIn(): boolean { return isAccessTokenValid() || userState.refreshToken != null } -/** - * Returns the current signed-in username from locally available auth state only. - * - * The returned value is unresolved: it comes from local cached state and may lag behind the - * canonical signed-in user returned by the backend. - * - * Use this only at boundaries that need a synchronous session-scoped identity hint, such as - * temporary route derivation or user-scoped storage. Do not use it for behavior-sensitive checks - * like ownership, permissions, or other logic that should depend on canonical backend data. - */ -export function getUnresolvedSignedInUsername(): string | null { - if (!isSignedIn()) return null - return userState.username -} - const signedInUserStaleTime = 60 * 1000 // 1min -const signedInUserQueryKey = computed(() => ['signed-in-user', signedInUserQueryScope.value]) +const signedInUserQueryKey = computed(() => ['signed-in-user', authSessionScope.value]) function useSignedInUserQuery() { return useQueryWithCache({ @@ -234,13 +178,10 @@ export type SignedInState = export function useSignedInStateQuery() { const signedInUserQuery = useSignedInUserQuery() return useQuery(async (ctx) => { - if (!isSignedIn()) { - syncCachedSignedInUsername(null) - return { isSignedIn: false, user: null } - } - const scope = signedInUserQueryScope.value + if (!isSignedIn()) return { isSignedIn: false, user: null } + const scope = authSessionScope.value const user = await composeQuery(ctx, signedInUserQuery) - syncCachedSignedInUsername(user.username, scope) + if (scope !== authSessionScope.value) return { isSignedIn: false, user: null } return { isSignedIn: true, user } }) } @@ -264,7 +205,6 @@ export function useUpdateSignedInUser() { params: Pick ) { const updated = await apis.updateSignedInUser(params) - syncCachedSignedInUsername(updated.username) queryCache.invalidate(signedInUserQueryKey.value) queryCache.invalidate(getUserQueryKey(updated.username)) return updated From 0590eb471558a8edaf7761ff6f1c1c419e6996e0 Mon Sep 17 00:00:00 2001 From: chennan Date: Tue, 2 Jun 2026 20:00:25 +0800 Subject: [PATCH 6/7] restore cached username for user-scoped state --- .../src/components/copilot/CopilotRoot.vue | 40 ++------------ .../src/components/tutorials/TutorialRoot.vue | 12 +---- spx-gui/src/components/tutorials/tutorial.ts | 14 ++--- .../xgo-code-editor/ui/CodeEditorUI.vue | 49 ++--------------- spx-gui/src/pages/editor/entry.vue | 53 ------------------- spx-gui/src/router.ts | 30 +++++++++-- spx-gui/src/stores/user/signed-in.test.ts | 18 ++++++- spx-gui/src/stores/user/signed-in.ts | 42 ++++++++++++--- spx-gui/src/utils/user-storage.ts | 53 +++++-------------- 9 files changed, 105 insertions(+), 206 deletions(-) delete mode 100644 spx-gui/src/pages/editor/entry.vue diff --git a/spx-gui/src/components/copilot/CopilotRoot.vue b/spx-gui/src/components/copilot/CopilotRoot.vue index cfa64ec193..6df7ed6302 100644 --- a/spx-gui/src/components/copilot/CopilotRoot.vue +++ b/spx-gui/src/components/copilot/CopilotRoot.vue @@ -1,7 +1,7 @@ diff --git a/spx-gui/src/router.ts b/spx-gui/src/router.ts index 0e57b31b67..a15d8af600 100644 --- a/spx-gui/src/router.ts +++ b/spx-gui/src/router.ts @@ -2,7 +2,7 @@ import type { App } from 'vue' import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router' import { searchKeywordQueryParamName } from '@/pages/community/search.vue' import type { ExploreOrder } from './apis/project' -import { initiateSignIn, isSignedIn } from './stores/user' +import { initiateSignIn, isSignedIn, getUnresolvedSignedInUsername } from './stores/user' export function getProjectEditorRoute(ownerName: string, projectName: string, publish = false) { ownerName = encodeURIComponent(ownerName) @@ -11,8 +11,14 @@ export function getProjectEditorRoute(ownerName: string, projectName: string, pu } export function getOwnProjectEditorRoute(projectName: string, publish = false) { - projectName = encodeURIComponent(projectName) - return publish ? `/editor/${projectName}?publish` : `/editor/${projectName}` + // TODO: Remove this helper after splitting "open my project editor" into two layers: + // - a pure self-entry route builder like `/editor/:projectName` + // - async resolution of the canonical signed-in user at that route boundary + // Then navigate to `/editor/:owner/:project` with backend-confirmed signed-in user data, + // instead of deriving owner name from unresolved local auth state synchronously. + const username = getUnresolvedSignedInUsername() + if (username == null) throw new Error('User not signed in') + return getProjectEditorRoute(username, projectName, publish) } export function getProjectPageRoute(owner: string, name: string) { @@ -133,8 +139,22 @@ const routes: Array = [ }, { path: '/editor/:projectNameInput', - component: () => import('@/pages/editor/entry.vue'), - props: true + redirect(to) { + const { projectNameInput } = to.params + // TODO: Replace this synchronous redirect with an async entry boundary (for example `beforeEnter`) that: + // - checks/initiates sign-in + // - awaits canonical signed-in user data + // - redirects to `/editor/:owner/:project` + // That would let router stop depending on unresolved local username hints here. + const username = getUnresolvedSignedInUsername() + // Route with `redirect` will not trigger the global `beforeEach` guard, + // so we need to check sign-in status here. + if (username == null) { + initiateSignIn() + throw new Error('User not signed in') // prevent router from redirecting + } + return getProjectEditorRoute(username, projectNameInput as string) + } }, { path: '/sign-in/callback', diff --git a/spx-gui/src/stores/user/signed-in.test.ts b/spx-gui/src/stores/user/signed-in.test.ts index 25f33f5f04..a87f6134ff 100644 --- a/spx-gui/src/stores/user/signed-in.test.ts +++ b/spx-gui/src/stores/user/signed-in.test.ts @@ -6,6 +6,7 @@ const exchangeForAccessToken = vi.fn() const refreshAccessToken = vi.fn() const signinRedirect = vi.fn() const getSignedInUser = vi.fn() +const clientGet = vi.fn() const useVueQueryMock = vi.fn() class MockCasdoorSdk { @@ -26,6 +27,15 @@ vi.mock('casdoor-js-sdk', () => ({ default: MockCasdoorSdk })) +const Client = vi.fn(function MockClient(this: { get: typeof clientGet; setTokenProvider: ReturnType }) { + this.get = clientGet + this.setTokenProvider = vi.fn() +}) + +vi.mock('@/apis/common/client', () => ({ + Client +})) + vi.mock('@/apis/user', async (importOriginal) => { const actual = await importOriginal() return { @@ -50,6 +60,8 @@ describe('signed-in user query key scope', () => { refreshAccessToken.mockReset() signinRedirect.mockReset() getSignedInUser.mockReset() + clientGet.mockReset() + Client.mockClear() useVueQueryMock.mockReset() useVueQueryMock.mockReturnValue({ isLoading: ref(false), @@ -69,6 +81,7 @@ describe('signed-in user query key scope', () => { it('should change the signed-in user query key across sign-in and sign-out transitions', async () => { const userStore = await import('./signed-in') + clientGet.mockResolvedValue({ username: 'alice' }) withSetup(() => userStore.useSignedInUser()) expect(useVueQueryMock).toHaveBeenLastCalledWith(expect.objectContaining({ queryKey: expect.any(Object) })) @@ -86,11 +99,14 @@ describe('signed-in user query key scope', () => { expect(queryKey.value).toEqual(['signed-in-user', 2]) }) - it('should not eagerly validate token by fetching signed-in user during sign-in', async () => { + it('should resolve username from the access token when signing in with a token', async () => { const userStore = await import('./signed-in') + clientGet.mockResolvedValue({ username: 'alice' }) await expect(userStore.signInWithAccessToken('token-a')).resolves.toBeUndefined() expect(userStore.isSignedIn()).toBe(true) + expect(userStore.getUnresolvedSignedInUsername()).toBe('alice') + expect(clientGet).toHaveBeenCalledWith('/user') expect(getSignedInUser).not.toHaveBeenCalled() }) diff --git a/spx-gui/src/stores/user/signed-in.ts b/spx-gui/src/stores/user/signed-in.ts index c3e76c360a..3c56f8c6b8 100644 --- a/spx-gui/src/stores/user/signed-in.ts +++ b/spx-gui/src/stores/user/signed-in.ts @@ -1,5 +1,6 @@ import { reactive, watchEffect, computed, ref } from 'vue' import Sdk from 'casdoor-js-sdk' +import { Client } from '@/apis/common/client' import { casdoorConfig } from '@/utils/env' import { useQueryWithCache, useQueryCache, useQuery, composeQuery } from '@/utils/query' import { useAction } from '@/utils/exception' @@ -18,16 +19,14 @@ const userStateStorageKey = 'spx-user' const userState = reactive({ accessToken: null as string | null, accessTokenExpiresAt: null as number | null, - refreshToken: null as string | null + refreshToken: null as string | null, + username: null as string | null }) export function initUserState() { const stored = localStorage.getItem(userStateStorageKey) if (stored != null) { - const parsed = JSON.parse(stored) - userState.accessToken = parsed.accessToken ?? null - userState.accessTokenExpiresAt = parsed.accessTokenExpiresAt ?? null - userState.refreshToken = parsed.refreshToken ?? null + Object.assign(userState, JSON.parse(stored)) } watchEffect(() => localStorage.setItem(userStateStorageKey, JSON.stringify(userState))) } @@ -38,10 +37,19 @@ interface TokenResponse { refresh_token: string } -function handleTokenResponse(resp: TokenResponse) { +async function fetchSignedInUsernameByAccessToken(accessToken: string) { + const client = new Client() + client.setTokenProvider(async () => accessToken) + const user = (await client.get('/user')) as SignedInUser + return user.username +} + +async function handleTokenResponse(resp: TokenResponse) { + const username = await fetchSignedInUsernameByAccessToken(resp.access_token) userState.accessToken = resp.access_token userState.accessTokenExpiresAt = resp.expires_in ? Date.now() + resp.expires_in * 1000 : null userState.refreshToken = resp.refresh_token + userState.username = username } /** @@ -71,14 +79,16 @@ export function initiateSignIn( export async function completeSignIn() { const resp = await casdoorSdk.exchangeForAccessToken() - handleTokenResponse(resp) + await handleTokenResponse(resp) bumpAuthSessionScope() } export async function signInWithAccessToken(accessToken: string) { + const username = await fetchSignedInUsernameByAccessToken(accessToken) userState.accessToken = accessToken userState.accessTokenExpiresAt = null userState.refreshToken = null + userState.username = username bumpAuthSessionScope() } @@ -95,6 +105,7 @@ export function signOut() { userState.accessToken = null userState.accessTokenExpiresAt = null userState.refreshToken = null + userState.username = null } const tokenExpiryDelta = 60 * 1000 // 1 minute in milliseconds @@ -111,7 +122,7 @@ export async function ensureAccessToken(): Promise { tokenRefreshPromise = (async () => { try { const resp = await casdoorSdk.refreshAccessToken(userState.refreshToken!) - handleTokenResponse(resp) + await handleTokenResponse(resp) } catch (e) { console.error('failed to refresh access token', e) throw e @@ -141,6 +152,21 @@ export function isSignedIn(): boolean { return isAccessTokenValid() || userState.refreshToken != null } +/** + * Returns the current signed-in username from locally available auth state only. + * + * The returned value is unresolved: it comes from local cached state and may lag behind the + * canonical signed-in user returned by the backend. + * + * Use this only at boundaries that need a synchronous session-scoped identity hint, such as + * temporary route derivation or user-scoped storage. Do not use it for behavior-sensitive checks + * like ownership, permissions, or other logic that should depend on canonical backend data. + */ +export function getUnresolvedSignedInUsername(): string | null { + if (!isSignedIn()) return null + return userState.username +} + const signedInUserStaleTime = 60 * 1000 // 1min const signedInUserQueryKey = computed(() => ['signed-in-user', authSessionScope.value]) diff --git a/spx-gui/src/utils/user-storage.ts b/spx-gui/src/utils/user-storage.ts index 732ec18eeb..7821ca68b8 100644 --- a/spx-gui/src/utils/user-storage.ts +++ b/spx-gui/src/utils/user-storage.ts @@ -1,4 +1,5 @@ -import { computed, ref, toValue, type MaybeRefOrGetter } from 'vue' +import { computed, ref } from 'vue' +import { getUnresolvedSignedInUsername } from '@/stores/user' import { isObject, isString } from 'lodash' type IStorage = { @@ -28,44 +29,18 @@ function createUserScopeValue(user: string, value: T): UserScopeValue { } } -/** - * Resolved value of a user/session storage scope. - * - * Scope semantics: - * - `string`: resolved signed-in scope - * - `null`: resolved guest scope - * - `undefined`: scope not resolved yet - */ -export type UserStorageScopeValue = string | null | undefined - -/** - * Synchronous user/session scope used to isolate stored values. - * - * Callers must provide this explicitly instead of letting the storage utility derive it from auth - * state. That keeps this utility generic and avoids coupling it to any particular signed-in-user - * caching strategy. - */ -export type UserStorageScope = MaybeRefOrGetter - // private -function userStorageRef(key: string, initialValue: T, scope: UserStorageScope, storage: IStorage = localStorage) { - // User-scoped storage must stay synchronously readable, so callers need to pass a scope that is - // already available locally. - // - // While scope is still `undefined`, this helper avoids reading or writing storage so startup - // windows do not accidentally use the wrong bucket before the real scope is known. - const normalizedScope = computed(() => { - const currentScope = toValue(scope) as UserStorageScopeValue - if (currentScope === undefined) return undefined - return currentScope ?? unauthorized - }) +function userStorageRef(key: string, initialValue: T, storage: IStorage = localStorage) { + // Ideally user-scoped storage would use a canonical signed-in username. We currently use the + // unresolved signed-in username as a temporary fallback because this scope needs to be computed + // synchronously from locally available session state. + const scope = computed(() => getUnresolvedSignedInUsername() ?? unauthorized) const counter = ref(0) return computed({ get() { // eslint-disable-next-line @typescript-eslint/no-unused-expressions counter.value - const currentScope = normalizedScope.value - if (currentScope === undefined) return initialValue + const currentScope = scope.value const exportedValue = storage.getItem(key) if (exportedValue == null) { return initialValue @@ -86,22 +61,20 @@ function userStorageRef(key: string, initialValue: T, scope: UserStorageScope return value == null ? initialValue : value }, set(newValue) { - const currentScope = normalizedScope.value - if (currentScope === undefined) return if (newValue === initialValue) { storage.removeItem(key) } else { - storage.setItem(key, JSON.stringify(createUserScopeValue(currentScope, newValue))) + storage.setItem(key, JSON.stringify(createUserScopeValue(scope.value, newValue))) } counter.value++ } }) } -export function userLocalStorageRef(key: string, initialValue: T, scope: UserStorageScope) { - return userStorageRef(key, initialValue, scope, localStorage) +export function userLocalStorageRef(key: string, initialValue: T) { + return userStorageRef(key, initialValue, localStorage) } -export function userSessionStorageRef(key: string, initialValue: T, scope: UserStorageScope) { - return userStorageRef(key, initialValue, scope, sessionStorage) +export function userSessionStorageRef(key: string, initialValue: T) { + return userStorageRef(key, initialValue, sessionStorage) } From 1523f0e6bffd585360b8d19f6a103111230c2cf5 Mon Sep 17 00:00:00 2001 From: chennan Date: Thu, 4 Jun 2026 11:40:00 +0800 Subject: [PATCH 7/7] refine signed-in user fetching and clarify auth session versioning --- spx-gui/src/apis/common/client.test.ts | 14 +++++++ spx-gui/src/apis/common/client.ts | 4 +- spx-gui/src/apis/user.ts | 4 +- spx-gui/src/stores/user/signed-in.test.ts | 25 ++++-------- spx-gui/src/stores/user/signed-in.ts | 50 +++++++++++------------ 5 files changed, 48 insertions(+), 49 deletions(-) diff --git a/spx-gui/src/apis/common/client.test.ts b/spx-gui/src/apis/common/client.test.ts index 447cba80ac..474e7f7874 100644 --- a/spx-gui/src/apis/common/client.test.ts +++ b/spx-gui/src/apis/common/client.test.ts @@ -112,4 +112,18 @@ describe('Client', () => { expect(globalFetchMock).toHaveBeenCalledTimes(1) }) }) + + describe('explicit authorization headers', () => { + it('should preserve an explicit Authorization header instead of overwriting it with the token provider', async () => { + fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ username: 'alice' }), { status: 200 })) + client.setTokenProvider(async () => 'shared-token') + + await client.get('/user', undefined, { + headers: new Headers({ Authorization: 'Bearer direct-token' }) + }) + + const req = fetchMock.mock.calls[0]?.[0] as Request + expect(req.headers.get('Authorization')).toBe('Bearer direct-token') + }) + }) }) diff --git a/spx-gui/src/apis/common/client.ts b/spx-gui/src/apis/common/client.ts index 27f2ad19e0..14c0b0e9f6 100644 --- a/spx-gui/src/apis/common/client.ts +++ b/spx-gui/src/apis/common/client.ts @@ -97,7 +97,7 @@ export class Client { options?.signal?.throwIfAborted() const headers = options?.headers ?? new Headers() if (body != null) headers.set('Content-Type', 'application/json') - if (token != null) headers.set('Authorization', `Bearer ${token}`) + if (token != null && !headers.has('Authorization')) headers.set('Authorization', `Bearer ${token}`) if (sentryTraceHeader != null) headers.set('Sentry-Trace', sentryTraceHeader) if (sentryBaggageHeader != null) headers.set('Baggage', sentryBaggageHeader) return new Request(url, { method, headers, body }) @@ -143,7 +143,7 @@ export class Client { const token = await this.tokenProvider() options?.signal?.throwIfAborted() const headers = options?.headers ?? new Headers() - if (token != null) headers.set('Authorization', `Bearer ${token}`) + if (token != null && !headers.has('Authorization')) headers.set('Authorization', `Bearer ${token}`) if (sentryTraceHeader != null) headers.set('Sentry-Trace', sentryTraceHeader) if (sentryBaggageHeader != null) headers.set('Baggage', sentryBaggageHeader) const req = new Request(url, { method, headers, body: payload }) diff --git a/spx-gui/src/apis/user.ts b/spx-gui/src/apis/user.ts index a7cda5fa20..f0d21074f8 100644 --- a/spx-gui/src/apis/user.ts +++ b/spx-gui/src/apis/user.ts @@ -48,8 +48,8 @@ export async function isUsernameTaken(username: string) { } } -export function getSignedInUser(): Promise { - return client.get(`/user`) as Promise +export function getSignedInUser(options?: { headers?: Headers }): Promise { + return client.get(`/user`, undefined, options) as Promise } export type UpdateSignedInUserParams = Partial> diff --git a/spx-gui/src/stores/user/signed-in.test.ts b/spx-gui/src/stores/user/signed-in.test.ts index a87f6134ff..445fec6c4c 100644 --- a/spx-gui/src/stores/user/signed-in.test.ts +++ b/spx-gui/src/stores/user/signed-in.test.ts @@ -6,7 +6,6 @@ const exchangeForAccessToken = vi.fn() const refreshAccessToken = vi.fn() const signinRedirect = vi.fn() const getSignedInUser = vi.fn() -const clientGet = vi.fn() const useVueQueryMock = vi.fn() class MockCasdoorSdk { @@ -27,15 +26,6 @@ vi.mock('casdoor-js-sdk', () => ({ default: MockCasdoorSdk })) -const Client = vi.fn(function MockClient(this: { get: typeof clientGet; setTokenProvider: ReturnType }) { - this.get = clientGet - this.setTokenProvider = vi.fn() -}) - -vi.mock('@/apis/common/client', () => ({ - Client -})) - vi.mock('@/apis/user', async (importOriginal) => { const actual = await importOriginal() return { @@ -52,7 +42,7 @@ vi.mock('@/utils/query', async (importOriginal) => { } }) -describe('signed-in user query key scope', () => { +describe('signed-in user query key version', () => { beforeEach(() => { localStorage.clear() sessionStorage.clear() @@ -60,8 +50,6 @@ describe('signed-in user query key scope', () => { refreshAccessToken.mockReset() signinRedirect.mockReset() getSignedInUser.mockReset() - clientGet.mockReset() - Client.mockClear() useVueQueryMock.mockReset() useVueQueryMock.mockReturnValue({ isLoading: ref(false), @@ -81,7 +69,7 @@ describe('signed-in user query key scope', () => { it('should change the signed-in user query key across sign-in and sign-out transitions', async () => { const userStore = await import('./signed-in') - clientGet.mockResolvedValue({ username: 'alice' }) + getSignedInUser.mockResolvedValue({ username: 'alice' }) withSetup(() => userStore.useSignedInUser()) expect(useVueQueryMock).toHaveBeenLastCalledWith(expect.objectContaining({ queryKey: expect.any(Object) })) @@ -101,16 +89,17 @@ describe('signed-in user query key scope', () => { it('should resolve username from the access token when signing in with a token', async () => { const userStore = await import('./signed-in') - clientGet.mockResolvedValue({ username: 'alice' }) + getSignedInUser.mockResolvedValue({ username: 'alice' }) await expect(userStore.signInWithAccessToken('token-a')).resolves.toBeUndefined() expect(userStore.isSignedIn()).toBe(true) expect(userStore.getUnresolvedSignedInUsername()).toBe('alice') - expect(clientGet).toHaveBeenCalledWith('/user') - expect(getSignedInUser).not.toHaveBeenCalled() + expect(getSignedInUser).toHaveBeenCalledWith({ + headers: new Headers({ Authorization: 'Bearer token-a' }) + }) }) - it('should not bump auth-session scope when resolving access token for a guest session', async () => { + it('should not bump auth-session version when resolving access token for a guest session', async () => { const userStore = await import('./signed-in') withSetup(() => userStore.useSignedInUser()) diff --git a/spx-gui/src/stores/user/signed-in.ts b/spx-gui/src/stores/user/signed-in.ts index 3c56f8c6b8..f197cb6f58 100644 --- a/spx-gui/src/stores/user/signed-in.ts +++ b/spx-gui/src/stores/user/signed-in.ts @@ -1,6 +1,5 @@ import { reactive, watchEffect, computed, ref } from 'vue' import Sdk from 'casdoor-js-sdk' -import { Client } from '@/apis/common/client' import { casdoorConfig } from '@/utils/env' import { useQueryWithCache, useQueryCache, useQuery, composeQuery } from '@/utils/query' import { useAction } from '@/utils/exception' @@ -38,9 +37,11 @@ interface TokenResponse { } async function fetchSignedInUsernameByAccessToken(accessToken: string) { - const client = new Client() - client.setTokenProvider(async () => accessToken) - const user = (await client.get('/user')) as SignedInUser + const user = await apis.getSignedInUser({ + headers: new Headers({ + Authorization: `Bearer ${accessToken}` + }) + }) return user.username } @@ -53,17 +54,20 @@ async function handleTokenResponse(resp: TokenResponse) { } /** - * Local auth-session scope for signed-in-user queries and derived async reads. + * Monotonic local auth-session version for signed-in-user queries and derived async reads. + * + * This is bumped whenever local auth state crosses a session boundary, such as sign-in or sign-out. * - * This is bumped whenever local auth state crosses a session boundary, such as sign-in or sign-out, - * so cached data and in-flight async results from an older local auth session are not consumed in the - * current session. + * It is intentionally different from imperative cache invalidation: + * - the version becomes part of the query key, so each auth session gets an isolated cache entry + * - derived async reads can compare the captured version before/after awaiting, which prevents stale + * results from an older auth session from being consumed in the current one */ -const authSessionScope = ref(0) +const authSessionVersion = ref(0) -function bumpAuthSessionScope() { - authSessionScope.value++ - return authSessionScope.value +function bumpAuthSessionVersion() { + authSessionVersion.value++ + return authSessionVersion.value } export function initiateSignIn( @@ -80,7 +84,7 @@ export function initiateSignIn( export async function completeSignIn() { const resp = await casdoorSdk.exchangeForAccessToken() await handleTokenResponse(resp) - bumpAuthSessionScope() + bumpAuthSessionVersion() } export async function signInWithAccessToken(accessToken: string) { @@ -89,7 +93,7 @@ export async function signInWithAccessToken(accessToken: string) { userState.accessTokenExpiresAt = null userState.refreshToken = null userState.username = username - bumpAuthSessionScope() + bumpAuthSessionVersion() } function hasLocalAuthState() { @@ -98,10 +102,10 @@ function hasLocalAuthState() { export function signOut() { // `ensureAccessToken()` is also used by guest/public API requests. If we are already effectively - // signed out, returning early avoids bumping `authSessionScope` for those requests, which would + // signed out, returning early avoids bumping `authSessionVersion` for those requests, which would // otherwise retrigger signed-in-state consumers and can cascade into startup update loops. if (!hasLocalAuthState()) return - bumpAuthSessionScope() + bumpAuthSessionVersion() userState.accessToken = null userState.accessTokenExpiresAt = null userState.refreshToken = null @@ -168,7 +172,7 @@ export function getUnresolvedSignedInUsername(): string | null { } const signedInUserStaleTime = 60 * 1000 // 1min -const signedInUserQueryKey = computed(() => ['signed-in-user', authSessionScope.value]) +const signedInUserQueryKey = computed(() => ['signed-in-user', authSessionVersion.value]) function useSignedInUserQuery() { return useQueryWithCache({ @@ -205,9 +209,9 @@ export function useSignedInStateQuery() { const signedInUserQuery = useSignedInUserQuery() return useQuery(async (ctx) => { if (!isSignedIn()) return { isSignedIn: false, user: null } - const scope = authSessionScope.value + const version = authSessionVersion.value const user = await composeQuery(ctx, signedInUserQuery) - if (scope !== authSessionScope.value) return { isSignedIn: false, user: null } + if (version !== authSessionVersion.value) return { isSignedIn: false, user: null } return { isSignedIn: true, user } }) } @@ -245,17 +249,9 @@ export function useUpdateSignedInUser() { * Typically the caller may want to reload the route to trigger navigation guards or initiate sign-in manually. */ export function useModifySignedInUsername() { - const queryCache = useQueryCache() - return useAction( async function modifySignedInUsername(newUsername: string) { - const currentUser = await apis.getSignedInUser() - const oldUsername = currentUser.username - const updated = await apis.updateSignedInUser({ username: newUsername }) - queryCache.invalidate(signedInUserQueryKey.value) - queryCache.invalidate(getUserQueryKey(oldUsername)) - queryCache.invalidate(getUserQueryKey(updated.username)) signOut() return updated },