Skip to content
Open
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
1 change: 0 additions & 1 deletion spx-gui/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 0 additions & 1 deletion spx-gui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
25 changes: 2 additions & 23 deletions spx-gui/src/pages/sign-in/token.vue
Original file line number Diff line number Diff line change
Expand Up @@ -25,16 +25,14 @@
html-type="submit"
:loading="handleSubmit.isLoading.value"
>
{{ buttonText }}
{{ $t({ en: 'Sign in', zh: '登录' }) }}
</UIButton>
</footer>
</UIForm>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'
import { jwtDecode } from 'jwt-decode'
import { useI18n } from '@/utils/i18n'
import { usePageTitle } from '@/utils/utils'
import { useMessageHandle } from '@/utils/exception'
Expand All @@ -51,36 +49,17 @@ usePageTitle(title)
const router = useRouter()
const i18n = useI18n()

const username = ref<string | null>(null)
const buttonText = computed(() => {
if (username.value == null) return i18n.t({ en: 'Sign in', zh: '登录' })
return i18n.t({
en: `Sign in as ${username.value}`,
zh: `以 ${username.value} 登录`
})
})

const form = useForm({
token: ['', validateToken]
})

function validateToken(token: string) {
username.value = null
token = token.trim()
if (token === '')
return i18n.t({
en: 'Token is required',
zh: '请提供 Token'
})
Comment thread
cn0809 marked this conversation as resolved.
try {
const decoded = jwtDecode<{ name: string }>(token)
username.value = decoded.name
} catch (e) {
return i18n.t({
en: 'Invalid token: ' + e,
zh: '无效的 Token:' + e
})
}
}

function handleCancel() {
Expand All @@ -90,7 +69,7 @@ function handleCancel() {
const handleSubmit = useMessageHandle(
async () => {
const token = form.value.token.trim()
signInWithAccessToken(token)
await signInWithAccessToken(token)
router.push('/')
},
{
Expand Down
10 changes: 10 additions & 0 deletions spx-gui/src/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@ 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)
Expand Down Expand Up @@ -136,6 +141,11 @@ const routes: Array<RouteRecordRaw> = [
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.
Expand Down
13 changes: 8 additions & 5 deletions spx-gui/src/stores/following.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { computed, toRef, type WatchSource } from 'vue'
import { useQueryWithCache, useQueryCache } from '@/utils/query'
import { useAction } from '@/utils/exception'
import * as apis from '@/apis/user'
import { getUnresolvedSignedInUsername } from './user'
import { useSignedInUser } from './user'

function getFollowingQueryKey(signedInUsernameInput: string | null, username: string) {
return ['following', signedInUsernameInput, username]
Expand All @@ -12,11 +12,12 @@ const staleTime = 5 * 60 * 1000 // 5min

export function useIsFollowing(username: WatchSource<string>) {
const usernameRef = toRef(username)
const queryKey = computed(() => getFollowingQueryKey(getUnresolvedSignedInUsername(), usernameRef.value))
const signedInUser = useSignedInUser()
const queryKey = computed(() => getFollowingQueryKey(signedInUser.value?.username ?? null, usernameRef.value))
return useQueryWithCache({
queryKey,
async queryFn() {
const signedInUsernameInput = getUnresolvedSignedInUsername()
const signedInUsernameInput = signedInUser.value?.username ?? null
if (signedInUsernameInput == null || signedInUsernameInput === usernameRef.value) return false
return apis.isFollowing(usernameRef.value)
},
Expand All @@ -27,10 +28,11 @@ export function useIsFollowing(username: WatchSource<string>) {

export function useFollow() {
const queryCache = useQueryCache()
const signedInUser = useSignedInUser()
return useAction(
async function follow(username: string) {
await apis.follow(username)
const queryKey = getFollowingQueryKey(getUnresolvedSignedInUsername(), username)
const queryKey = getFollowingQueryKey(signedInUser.value?.username ?? null, username)
queryCache.invalidateWithOptimisticValue(queryKey, true)
},
{ en: 'Failed to follow', zh: '关注用户失败' }
Expand All @@ -39,10 +41,11 @@ export function useFollow() {

export function useUnfollow() {
const queryCache = useQueryCache()
const signedInUser = useSignedInUser()
return useAction(
async function unfollow(username: string) {
await apis.unfollow(username)
const queryKey = getFollowingQueryKey(getUnresolvedSignedInUsername(), username)
const queryKey = getFollowingQueryKey(signedInUser.value?.username ?? null, username)
queryCache.invalidateWithOptimisticValue(queryKey, false)
},
{ en: 'Failed to unfollow', zh: '取消关注失败' }
Expand Down
11 changes: 7 additions & 4 deletions spx-gui/src/stores/liking.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { computed, toRef, type WatchSource } from 'vue'
import { useQueryWithCache, useQueryCache } from '@/utils/query'
import { useAction } from '@/utils/exception'
import * as apis from '@/apis/project'
import { getUnresolvedSignedInUsername, isSignedIn } from './user'
import { isSignedIn, useSignedInUser } from './user'

function getLikingQueryKey(signedInUsernameInput: string | null, owner: string, name: string) {
return ['liking', signedInUsernameInput, owner, name]
Expand All @@ -12,9 +12,10 @@ const staleTime = 5 * 60 * 1000 // 5min

export function useIsLikingProject(project: WatchSource<{ owner: string; name: string }>) {
const projectRef = toRef(project)
const signedInUser = useSignedInUser()
const queryKey = computed(() => {
const { owner, name } = projectRef.value
return getLikingQueryKey(getUnresolvedSignedInUsername(), owner, name)
return getLikingQueryKey(signedInUser.value?.username ?? null, owner, name)
})
return useQueryWithCache({
queryKey,
Expand All @@ -29,10 +30,11 @@ export function useIsLikingProject(project: WatchSource<{ owner: string; name: s

export function useLikeProject() {
const queryCache = useQueryCache()
const signedInUser = useSignedInUser()
return useAction(
async function likeProject(owner: string, name: string) {
await apis.likeProject(owner, name)
const queryKey = getLikingQueryKey(getUnresolvedSignedInUsername(), owner, name)
const queryKey = getLikingQueryKey(signedInUser.value?.username ?? null, owner, name)
queryCache.invalidateWithOptimisticValue(queryKey, true)
},
{ en: 'Failed to like', zh: '标记喜欢失败' }
Expand All @@ -41,10 +43,11 @@ export function useLikeProject() {

export function useUnlikeProject() {
const queryCache = useQueryCache()
const signedInUser = useSignedInUser()
return useAction(
async function unlikeProject(owner: string, name: string) {
await apis.unlikeProject(owner, name)
const queryKey = getLikingQueryKey(getUnresolvedSignedInUsername(), owner, name)
const queryKey = getLikingQueryKey(signedInUser.value?.username ?? null, owner, name)
queryCache.invalidateWithOptimisticValue(queryKey, false)
},
{ en: 'Failed to unlike', zh: '取消喜欢失败' }
Expand Down
126 changes: 126 additions & 0 deletions spx-gui/src/stores/user/signed-in.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import { ref, shallowRef } from 'vue'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { withSetup } from '@/utils/test'

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 {
exchangeForAccessToken() {
return exchangeForAccessToken()
}

refreshAccessToken(...args: unknown[]) {
return refreshAccessToken(...args)
}

signin_redirect(...args: unknown[]) {
return signinRedirect(...args)
}
}

vi.mock('casdoor-js-sdk', () => ({
default: MockCasdoorSdk
}))

const Client = vi.fn(function MockClient(this: { get: typeof clientGet; setTokenProvider: ReturnType<typeof vi.fn> }) {
this.get = clientGet
this.setTokenProvider = vi.fn()
})

vi.mock('@/apis/common/client', () => ({
Client
}))

vi.mock('@/apis/user', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/apis/user')>()
return {
...actual,
getSignedInUser
}
})

vi.mock('@/utils/query', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/utils/query')>()
return {
...actual,
useQueryWithCache: useVueQueryMock
}
})

describe('signed-in user query key scope', () => {
beforeEach(() => {
localStorage.clear()
sessionStorage.clear()
exchangeForAccessToken.mockReset()
refreshAccessToken.mockReset()
signinRedirect.mockReset()
getSignedInUser.mockReset()
clientGet.mockReset()
Client.mockClear()
useVueQueryMock.mockReset()
useVueQueryMock.mockReturnValue({
isLoading: ref(false),
data: shallowRef(null),
error: shallowRef(null),
progress: shallowRef({ percentage: 0, timeLeft: null, desc: null }),
refetch: vi.fn()
})
vi.resetModules()
})

afterEach(async () => {
const userStore = await import('./signed-in')
userStore.signOut()
vi.restoreAllMocks()
})

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) }))
let queryKey = useVueQueryMock.mock.lastCall?.[0]?.queryKey
expect(queryKey.value).toEqual(['signed-in-user', 0])

await userStore.signInWithAccessToken('token-a')
withSetup(() => userStore.useSignedInUser())
queryKey = useVueQueryMock.mock.lastCall?.[0]?.queryKey
expect(queryKey.value).toEqual(['signed-in-user', 1])

userStore.signOut()
withSetup(() => userStore.useSignedInUser())
queryKey = useVueQueryMock.mock.lastCall?.[0]?.queryKey
expect(queryKey.value).toEqual(['signed-in-user', 2])
})

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()
})

it('should not bump auth-session scope when resolving access token for a guest session', async () => {
const userStore = await import('./signed-in')

withSetup(() => userStore.useSignedInUser())
let queryKey = useVueQueryMock.mock.lastCall?.[0]?.queryKey
expect(queryKey.value).toEqual(['signed-in-user', 0])

await expect(userStore.ensureAccessToken()).resolves.toBeNull()

withSetup(() => userStore.useSignedInUser())
queryKey = useVueQueryMock.mock.lastCall?.[0]?.queryKey
expect(queryKey.value).toEqual(['signed-in-user', 0])
})
})
Loading