From f664dc04021b1ec49802e0a52117b90074998496 Mon Sep 17 00:00:00 2001 From: chennan Date: Thu, 4 Jun 2026 18:04:02 +0800 Subject: [PATCH 1/6] refactor(spx-gui): remove jwt-decode and reduce cached username dependencies (#3224) * remove jwt-based username parsing from sign-in flow * reduce cached username dependencies * harden signed-in user sync and cache scoping * decouple user-scoped storage from cached usernames * remove local cached username * restore cached username for user-scoped state * refine signed-in user fetching and clarify auth session versioning * revert signed-in user query key with unresolved username * remove wrong comments --- spx-gui/package-lock.json | 1 - spx-gui/package.json | 1 - 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/pages/sign-in/token.vue | 25 +--------- spx-gui/src/router.ts | 10 ++++ spx-gui/src/stores/following.ts | 13 +++-- spx-gui/src/stores/liking.ts | 11 +++-- spx-gui/src/stores/user/signed-in.ts | 67 +++++++++++++------------- 10 files changed, 79 insertions(+), 71 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/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/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/account-web/App.vue b/spx-gui/src/account-web/App.vue new file mode 100644 index 0000000000..60eab2f436 --- /dev/null +++ b/spx-gui/src/account-web/App.vue @@ -0,0 +1,17 @@ + + + diff --git a/spx-gui/src/account-web/apis/account.ts b/spx-gui/src/account-web/apis/account.ts new file mode 100644 index 0000000000..e3a71acea6 --- /dev/null +++ b/spx-gui/src/account-web/apis/account.ts @@ -0,0 +1,86 @@ +import { Client } from '@/apis/common/client' +import { ApiException, ApiExceptionCode } from '@/apis/common/exception' +import type { OAuthContext } from '../utils/oauth' + +export interface AccountUser { + id: string + createdAt: string + updatedAt: string + username: string + displayName: string + avatar: string +} + +export interface CurrentAccountSession { + id: string + createdAt: string + updatedAt: string + lastUsedAt: string + expiresAt: string + current: boolean + user: AccountUser + userAgent?: string | null + ipAddress?: string | null +} + +export interface IdentityProvider { + name: string + displayName: string + enabled: boolean +} + +interface IdentityProviderListResponse { + data: IdentityProvider[] +} + +function filterEnabledIdentityProviders(providers: IdentityProvider[]): IdentityProvider[] { + return providers.filter((provider) => provider.enabled) +} + +const client = new Client({ baseUrl: '/api' }) + +function isUnauthorizedError(error: unknown) { + return ( + (error instanceof ApiException && error.code === ApiExceptionCode.errorUnauthorized) || + (error instanceof Error && error.message.includes('status 401 for api call:')) + ) +} + +export async function getCurrentAccountSession(): Promise { + try { + return (await client.get('/session')) as CurrentAccountSession + } catch (error) { + if (isUnauthorizedError(error)) return null + throw error + } +} + +export async function deleteCurrentAccountSession(): Promise { + await client.delete('/session') +} + +export async function getIdentityProviders(context?: OAuthContext): Promise { + const response = (await client.get( + '/identity-providers', + context == null + ? undefined + : { + clientID: context.clientId, + requestURI: context.requestUri + } + )) as IdentityProviderListResponse + return filterEnabledIdentityProviders(response.data) +} + +export interface PasswordSignInPayload { + username: string + password: string +} + +export async function createPasswordSession(payload: PasswordSignInPayload): Promise { + return (await client.post('/session', { + method: 'password', + username: payload.username, + password: payload.password + })) as CurrentAccountSession +} diff --git a/spx-gui/src/account-web/assets/bg-mobile.svg b/spx-gui/src/account-web/assets/bg-mobile.svg new file mode 100644 index 0000000000..4f6c0f55c4 --- /dev/null +++ b/spx-gui/src/account-web/assets/bg-mobile.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/spx-gui/src/account-web/assets/bg.svg b/spx-gui/src/account-web/assets/bg.svg new file mode 100644 index 0000000000..674cf7aec2 --- /dev/null +++ b/spx-gui/src/account-web/assets/bg.svg @@ -0,0 +1,121 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/spx-gui/src/account-web/assets/icons/eye-off.svg b/spx-gui/src/account-web/assets/icons/eye-off.svg new file mode 100644 index 0000000000..82732daee4 --- /dev/null +++ b/spx-gui/src/account-web/assets/icons/eye-off.svg @@ -0,0 +1,3 @@ + + + diff --git a/spx-gui/src/account-web/assets/icons/eye.svg b/spx-gui/src/account-web/assets/icons/eye.svg new file mode 100644 index 0000000000..4e494cce6d --- /dev/null +++ b/spx-gui/src/account-web/assets/icons/eye.svg @@ -0,0 +1,3 @@ + + + diff --git a/spx-gui/src/account-web/assets/icons/lock.svg b/spx-gui/src/account-web/assets/icons/lock.svg new file mode 100644 index 0000000000..55883e527b --- /dev/null +++ b/spx-gui/src/account-web/assets/icons/lock.svg @@ -0,0 +1,3 @@ + + + diff --git a/spx-gui/src/account-web/assets/icons/user.svg b/spx-gui/src/account-web/assets/icons/user.svg new file mode 100644 index 0000000000..80ccda9991 --- /dev/null +++ b/spx-gui/src/account-web/assets/icons/user.svg @@ -0,0 +1,3 @@ + + + diff --git a/spx-gui/src/account-web/assets/illustration-mobile.svg b/spx-gui/src/account-web/assets/illustration-mobile.svg new file mode 100644 index 0000000000..c83fde422e --- /dev/null +++ b/spx-gui/src/account-web/assets/illustration-mobile.svg @@ -0,0 +1,83 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/spx-gui/src/account-web/assets/illustration.svg b/spx-gui/src/account-web/assets/illustration.svg new file mode 100644 index 0000000000..ed071749cd --- /dev/null +++ b/spx-gui/src/account-web/assets/illustration.svg @@ -0,0 +1,76 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/spx-gui/src/account-web/assets/logo.svg b/spx-gui/src/account-web/assets/logo.svg new file mode 100644 index 0000000000..de7a1b92ce --- /dev/null +++ b/spx-gui/src/account-web/assets/logo.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/spx-gui/src/account-web/assets/providers/github-colorful.svg b/spx-gui/src/account-web/assets/providers/github-colorful.svg new file mode 100644 index 0000000000..76a843198b --- /dev/null +++ b/spx-gui/src/account-web/assets/providers/github-colorful.svg @@ -0,0 +1,3 @@ + + + diff --git a/spx-gui/src/account-web/assets/providers/github-monochrome.svg b/spx-gui/src/account-web/assets/providers/github-monochrome.svg new file mode 100644 index 0000000000..67e4dc4ee1 --- /dev/null +++ b/spx-gui/src/account-web/assets/providers/github-monochrome.svg @@ -0,0 +1,3 @@ + + + diff --git a/spx-gui/src/account-web/assets/providers/google-colorful.svg b/spx-gui/src/account-web/assets/providers/google-colorful.svg new file mode 100644 index 0000000000..bad8a6ebea --- /dev/null +++ b/spx-gui/src/account-web/assets/providers/google-colorful.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/spx-gui/src/account-web/assets/providers/google-monochrome.svg b/spx-gui/src/account-web/assets/providers/google-monochrome.svg new file mode 100644 index 0000000000..f8706cc74b --- /dev/null +++ b/spx-gui/src/account-web/assets/providers/google-monochrome.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/spx-gui/src/account-web/assets/providers/qq-colorful.svg b/spx-gui/src/account-web/assets/providers/qq-colorful.svg new file mode 100644 index 0000000000..e12d16416b --- /dev/null +++ b/spx-gui/src/account-web/assets/providers/qq-colorful.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/spx-gui/src/account-web/assets/providers/qq-monochrome.svg b/spx-gui/src/account-web/assets/providers/qq-monochrome.svg new file mode 100644 index 0000000000..d463de44d3 --- /dev/null +++ b/spx-gui/src/account-web/assets/providers/qq-monochrome.svg @@ -0,0 +1,3 @@ + + + diff --git a/spx-gui/src/account-web/assets/providers/wechat-colorful.svg b/spx-gui/src/account-web/assets/providers/wechat-colorful.svg new file mode 100644 index 0000000000..27105300ec --- /dev/null +++ b/spx-gui/src/account-web/assets/providers/wechat-colorful.svg @@ -0,0 +1,4 @@ + + + + diff --git a/spx-gui/src/account-web/assets/providers/wechat-monochrome.svg b/spx-gui/src/account-web/assets/providers/wechat-monochrome.svg new file mode 100644 index 0000000000..264315c00a --- /dev/null +++ b/spx-gui/src/account-web/assets/providers/wechat-monochrome.svg @@ -0,0 +1,4 @@ + + + + diff --git a/spx-gui/src/account-web/assets/providers/x-colorful.svg b/spx-gui/src/account-web/assets/providers/x-colorful.svg new file mode 100644 index 0000000000..fa4d8daba8 --- /dev/null +++ b/spx-gui/src/account-web/assets/providers/x-colorful.svg @@ -0,0 +1,3 @@ + + + diff --git a/spx-gui/src/account-web/assets/providers/x-monochrome.svg b/spx-gui/src/account-web/assets/providers/x-monochrome.svg new file mode 100644 index 0000000000..59a1fa4979 --- /dev/null +++ b/spx-gui/src/account-web/assets/providers/x-monochrome.svg @@ -0,0 +1,3 @@ + + + diff --git a/spx-gui/src/account-web/components/login/AccountSessionSection.vue b/spx-gui/src/account-web/components/login/AccountSessionSection.vue new file mode 100644 index 0000000000..e3fd931e8b --- /dev/null +++ b/spx-gui/src/account-web/components/login/AccountSessionSection.vue @@ -0,0 +1,48 @@ + + + diff --git a/spx-gui/src/account-web/components/login/InputWithIcon.vue b/spx-gui/src/account-web/components/login/InputWithIcon.vue new file mode 100644 index 0000000000..cfc4627213 --- /dev/null +++ b/spx-gui/src/account-web/components/login/InputWithIcon.vue @@ -0,0 +1,101 @@ + + + + + diff --git a/spx-gui/src/account-web/components/login/LoginButton.vue b/spx-gui/src/account-web/components/login/LoginButton.vue new file mode 100644 index 0000000000..fe4fdab6d1 --- /dev/null +++ b/spx-gui/src/account-web/components/login/LoginButton.vue @@ -0,0 +1,66 @@ + + + + + diff --git a/spx-gui/src/account-web/components/login/LoginForm.vue b/spx-gui/src/account-web/components/login/LoginForm.vue new file mode 100644 index 0000000000..689e7a8170 --- /dev/null +++ b/spx-gui/src/account-web/components/login/LoginForm.vue @@ -0,0 +1,173 @@ + + + diff --git a/spx-gui/src/account-web/components/login/LoginOptionsSection.vue b/spx-gui/src/account-web/components/login/LoginOptionsSection.vue new file mode 100644 index 0000000000..7781e2060f --- /dev/null +++ b/spx-gui/src/account-web/components/login/LoginOptionsSection.vue @@ -0,0 +1,35 @@ + + + diff --git a/spx-gui/src/account-web/components/login/PasswordLoginSection.vue b/spx-gui/src/account-web/components/login/PasswordLoginSection.vue new file mode 100644 index 0000000000..653fd1c356 --- /dev/null +++ b/spx-gui/src/account-web/components/login/PasswordLoginSection.vue @@ -0,0 +1,146 @@ + + + + + diff --git a/spx-gui/src/account-web/components/login/ProviderLoginButton.vue b/spx-gui/src/account-web/components/login/ProviderLoginButton.vue new file mode 100644 index 0000000000..86bb7d0770 --- /dev/null +++ b/spx-gui/src/account-web/components/login/ProviderLoginButton.vue @@ -0,0 +1,87 @@ + + + diff --git a/spx-gui/src/account-web/components/login/UsernamePasswordLink.vue b/spx-gui/src/account-web/components/login/UsernamePasswordLink.vue new file mode 100644 index 0000000000..009cd0bbbf --- /dev/null +++ b/spx-gui/src/account-web/components/login/UsernamePasswordLink.vue @@ -0,0 +1,24 @@ + + + diff --git a/spx-gui/src/account-web/components/login/XBuilderLoginPageMobile.vue b/spx-gui/src/account-web/components/login/XBuilderLoginPageMobile.vue new file mode 100644 index 0000000000..ddbef5a0f6 --- /dev/null +++ b/spx-gui/src/account-web/components/login/XBuilderLoginPageMobile.vue @@ -0,0 +1,46 @@ + + + + + diff --git a/spx-gui/src/account-web/components/login/XBuilderLoginPagePc.vue b/spx-gui/src/account-web/components/login/XBuilderLoginPagePc.vue new file mode 100644 index 0000000000..6a5fda6bc9 --- /dev/null +++ b/spx-gui/src/account-web/components/login/XBuilderLoginPagePc.vue @@ -0,0 +1,52 @@ + + + + + diff --git a/spx-gui/src/account-web/main.ts b/spx-gui/src/account-web/main.ts new file mode 100644 index 0000000000..390846298e --- /dev/null +++ b/spx-gui/src/account-web/main.ts @@ -0,0 +1,20 @@ +import '@/polyfills' +import '@/app.css' + +import { createApp, watchEffect } from 'vue' + +import { createI18n, normalizeLang } from '@/utils/i18n' +import { defaultLang } from '@/utils/env' + +import App from './App.vue' +import router from './router' + +const langLocalStorageKey = 'spx-gui-language' +const lang = normalizeLang(localStorage.getItem(langLocalStorageKey) ?? defaultLang) +const i18n = createI18n({ lang }) + +watchEffect(() => { + localStorage.setItem(langLocalStorageKey, i18n.lang.value) +}) + +createApp(App).use(i18n).use(router).mount('#app') diff --git a/spx-gui/src/account-web/pages/sign-in.vue b/spx-gui/src/account-web/pages/sign-in.vue new file mode 100644 index 0000000000..5bc320a3de --- /dev/null +++ b/spx-gui/src/account-web/pages/sign-in.vue @@ -0,0 +1,30 @@ + + + diff --git a/spx-gui/src/account-web/router.ts b/spx-gui/src/account-web/router.ts new file mode 100644 index 0000000000..62cf4269e7 --- /dev/null +++ b/spx-gui/src/account-web/router.ts @@ -0,0 +1,27 @@ +import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router' + +export const accountWebRoutePaths = { + signIn: '/sign-in' +} as const + +const routes: Array = [ + { + path: '/', + redirect: accountWebRoutePaths.signIn + }, + { + path: accountWebRoutePaths.signIn, + component: () => import('@/account-web/pages/sign-in.vue') + }, + { + path: '/:pathMatch(.*)*', + redirect: accountWebRoutePaths.signIn + } +] + +const router = createRouter({ + history: createWebHistory(''), + routes +}) + +export default router diff --git a/spx-gui/src/account-web/utils/oauth.test.ts b/spx-gui/src/account-web/utils/oauth.test.ts new file mode 100644 index 0000000000..e82f7f68eb --- /dev/null +++ b/spx-gui/src/account-web/utils/oauth.test.ts @@ -0,0 +1,44 @@ +import { beforeEach, describe, expect, it } from 'vitest' + +import { + buildIdentityProviderAuthorizeUrl, + buildOAuthAuthorizeUrl, + clearPendingAuthorization, + hasPendingAuthorization, + markPendingAuthorization, + parseOAuthContext, + type OAuthContext +} from './oauth' + +describe('account-web oauth utils', () => { + const context: OAuthContext = { + clientId: 'xbuilder', + requestUri: 'urn:example:request:123' + } + + beforeEach(() => { + sessionStorage.clear() + window.history.replaceState({}, '', '/sign-in?clientID=xbuilder&requestURI=urn%3Aexample%3Arequest%3A123') + }) + + it('parses oauth context from the current location', () => { + expect(parseOAuthContext()).toEqual(context) + }) + + it('builds account authorize URLs against a relative facade base URL', () => { + expect(buildOAuthAuthorizeUrl(context)).toBe( + 'http://localhost:3000/api/oauth/authorize?client_id=xbuilder&request_uri=urn%3Aexample%3Arequest%3A123' + ) + expect(buildIdentityProviderAuthorizeUrl('github', context)).toBe( + 'http://localhost:3000/api/identity-providers/github/authorize?clientID=xbuilder&requestURI=urn%3Aexample%3Arequest%3A123' + ) + }) + + it('tracks pending authorization by oauth flow', () => { + expect(hasPendingAuthorization(context)).toBe(false) + markPendingAuthorization(context) + expect(hasPendingAuthorization(context)).toBe(true) + clearPendingAuthorization(context) + expect(hasPendingAuthorization(context)).toBe(false) + }) +}) diff --git a/spx-gui/src/account-web/utils/oauth.ts b/spx-gui/src/account-web/utils/oauth.ts new file mode 100644 index 0000000000..dcb2d81836 --- /dev/null +++ b/spx-gui/src/account-web/utils/oauth.ts @@ -0,0 +1,60 @@ +export interface OAuthContext { + clientId: string + requestUri: string +} + +export function parseOAuthContext(search: string = window.location.search): OAuthContext | null { + const params = new URLSearchParams(search) + const clientId = params.get('clientID')?.trim() ?? '' + const requestUri = params.get('requestURI')?.trim() ?? '' + if (clientId === '' || requestUri === '') return null + return { clientId, requestUri } +} + +export function getOAuthError(search: string = window.location.search): string | null { + const params = new URLSearchParams(search) + const error = params.get('error')?.trim() ?? '' + if (error === '') return null + const description = params.get('error_description')?.trim() ?? '' + return description === '' ? error : `${error}: ${description}` +} + +export function buildAccountApiUrl(path: string, query?: Record) { + const url = new URL(path, window.location.origin) + if (query != null) { + Object.entries(query).forEach(([key, value]) => { + url.searchParams.set(key, value) + }) + } + return url.toString() +} + +export function buildOAuthAuthorizeUrl(context: OAuthContext) { + return buildAccountApiUrl('/api/oauth/authorize', { + client_id: context.clientId, + request_uri: context.requestUri + }) +} + +export function buildIdentityProviderAuthorizeUrl(provider: string, context: OAuthContext) { + return buildAccountApiUrl(`/api/identity-providers/${encodeURIComponent(provider)}/authorize`, { + clientID: context.clientId, + requestURI: context.requestUri + }) +} + +function getPendingAuthorizationKey(context: OAuthContext) { + return `account-web:pending-authorization:${context.clientId}:${context.requestUri}` +} + +export function hasPendingAuthorization(context: OAuthContext) { + return sessionStorage.getItem(getPendingAuthorizationKey(context)) === '1' +} + +export function markPendingAuthorization(context: OAuthContext) { + sessionStorage.setItem(getPendingAuthorizationKey(context), '1') +} + +export function clearPendingAuthorization(context: OAuthContext) { + sessionStorage.removeItem(getPendingAuthorizationKey(context)) +} diff --git a/spx-gui/src/apis/account-oauth.test.ts b/spx-gui/src/apis/account-oauth.test.ts new file mode 100644 index 0000000000..5d0256afcd --- /dev/null +++ b/spx-gui/src/apis/account-oauth.test.ts @@ -0,0 +1,105 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const postForm = vi.fn() +const Client = vi.fn(function MockClient(this: { postForm: typeof postForm }) { + this.postForm = postForm +}) + +vi.mock('@/apis/common/client', () => ({ + Client +})) + +vi.mock('@/utils/env', () => ({ + accountOAuthClientId: 'client-123', + apiBaseUrl: '/api' +})) + +describe('account oauth apis', () => { + beforeEach(() => { + vi.resetModules() + Client.mockClear() + postForm.mockReset() + window.history.replaceState({}, '', '/') + }) + + it('should create pushed authorization requests with form-encoded oauth params', async () => { + postForm.mockResolvedValueOnce({ request_uri: 'urn:request:1' }) + const { createPushedAuthorizationRequest } = await import('./account-oauth') + + await expect(createPushedAuthorizationRequest({ state: 'state-1', codeChallenge: 'challenge-1' })).resolves.toEqual( + { + request_uri: 'urn:request:1' + } + ) + expect(postForm).toHaveBeenCalledWith('/account/oauth/par', { + client_id: 'client-123', + response_type: 'code', + redirect_uri: 'http://localhost:3000/sign-in/callback', + state: 'state-1', + code_challenge: 'challenge-1', + code_challenge_method: 'S256', + scope: 'account:user:read' + }) + }) + + it('should exchange authorization codes with form-encoded oauth params', async () => { + postForm.mockResolvedValueOnce({ access_token: 'token-1', expires_in: 3600 }) + const { exchangeAuthorizationCode } = await import('./account-oauth') + + await exchangeAuthorizationCode('code-1', 'verifier-1') + + expect(postForm).toHaveBeenCalledWith('/account/oauth/token', { + grant_type: 'authorization_code', + client_id: 'client-123', + code: 'code-1', + redirect_uri: 'http://localhost:3000/sign-in/callback', + code_verifier: 'verifier-1' + }) + }) + + it('should exchange refresh tokens with form-encoded oauth params', async () => { + postForm.mockResolvedValueOnce({ access_token: 'token-1', expires_in: 3600 }) + const { exchangeRefreshToken } = await import('./account-oauth') + + await exchangeRefreshToken('refresh-1') + + expect(postForm).toHaveBeenCalledWith('/account/oauth/token', { + grant_type: 'refresh_token', + client_id: 'client-123', + refresh_token: 'refresh-1' + }) + }) + + it('should revoke oauth tokens with form-encoded oauth params', async () => { + postForm.mockResolvedValueOnce(null) + const { revokeOAuthToken } = await import('./account-oauth') + + await revokeOAuthToken('token-1') + + expect(postForm).toHaveBeenCalledWith('/account/oauth/revoke', { + client_id: 'client-123', + token: 'token-1' + }) + }) + + it('should skip revoke when token is null', async () => { + const { revokeOAuthToken } = await import('./account-oauth') + + await expect(revokeOAuthToken(null)).resolves.toBeUndefined() + expect(postForm).not.toHaveBeenCalled() + }) + + it('should use a dedicated client instance for oauth endpoints', async () => { + await import('./account-oauth') + expect(Client).toHaveBeenCalledTimes(1) + expect(Client).toHaveBeenCalledWith() + }) + + it('should build the authorize URL against the main-site api origin', async () => { + const { getAccountOAuthAuthorizeUrl } = await import('./account-oauth') + + expect(getAccountOAuthAuthorizeUrl('urn:request:1')).toBe( + 'http://localhost:3000/account/oauth/authorize?client_id=client-123&request_uri=urn%3Arequest%3A1' + ) + }) +}) diff --git a/spx-gui/src/apis/account-oauth.ts b/spx-gui/src/apis/account-oauth.ts new file mode 100644 index 0000000000..05322c686a --- /dev/null +++ b/spx-gui/src/apis/account-oauth.ts @@ -0,0 +1,66 @@ +import { Client } from '@/apis/common/client' +import { accountOAuthClientId, apiBaseUrl } from '@/utils/env' + +const client = new Client() + +export interface PushedAuthorizationRequestResponse { + request_uri: string + expires_in?: number | null +} + +export interface AccountOAuthTokenResponse { + access_token: string + expires_in: number + refresh_token?: string | null + token_type?: string + scope?: string +} + +function getAccountOAuthRedirectUri() { + return `${window.location.origin}/sign-in/callback` +} + +export function getAccountOAuthAuthorizeUrl(requestUri: string) { + const url = new URL('/account/oauth/authorize', new URL(apiBaseUrl, window.location.origin)) + url.searchParams.set('client_id', accountOAuthClientId) + url.searchParams.set('request_uri', requestUri) + return url.toString() +} + +export function createPushedAuthorizationRequest(params: { state: string; codeChallenge: string }) { + return client.postForm('/account/oauth/par', { + client_id: accountOAuthClientId, + response_type: 'code', + redirect_uri: getAccountOAuthRedirectUri(), + state: params.state, + code_challenge: params.codeChallenge, + code_challenge_method: 'S256', + scope: 'account:user:read' + }) as Promise +} + +export function exchangeAuthorizationCode(code: string, codeVerifier: string) { + return client.postForm('/account/oauth/token', { + grant_type: 'authorization_code', + client_id: accountOAuthClientId, + code, + redirect_uri: getAccountOAuthRedirectUri(), + code_verifier: codeVerifier + }) as Promise +} + +export function exchangeRefreshToken(refreshToken: string) { + return client.postForm('/account/oauth/token', { + grant_type: 'refresh_token', + client_id: accountOAuthClientId, + refresh_token: refreshToken + }) as Promise +} + +export async function revokeOAuthToken(token: string | null) { + if (token == null) return + await client.postForm('/account/oauth/revoke', { + client_id: accountOAuthClientId, + token + }) +} diff --git a/spx-gui/src/apis/common/client.test.ts b/spx-gui/src/apis/common/client.test.ts index 474e7f7874..943271147f 100644 --- a/spx-gui/src/apis/common/client.test.ts +++ b/spx-gui/src/apis/common/client.test.ts @@ -95,6 +95,34 @@ describe('Client', () => { }) }) + describe('form requests', () => { + it('should submit application/x-www-form-urlencoded payloads', async () => { + fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ ok: true }), { status: 200 })) + + await client.postForm('/account/oauth/token', { + grant_type: 'refresh_token', + client_id: 'client-1', + refresh_token: 'refresh-1' + }) + + expect(fetchMock).toHaveBeenCalledTimes(1) + const request = fetchMock.mock.calls[0]![0] as Request + expect(request.headers.get('Content-Type')).toBe('application/x-www-form-urlencoded') + expect(await request.text()).toBe('grant_type=refresh_token&client_id=client-1&refresh_token=refresh-1') + }) + + it('should allow empty successful responses for form-encoded endpoints', async () => { + fetchMock.mockResolvedValueOnce(new Response(null, { status: 200 })) + + await expect( + client.postForm('/account/oauth/revoke', { + client_id: 'client-1', + token: 'token-1' + }) + ).resolves.toBeNull() + }) + }) + describe('default fetch binding', () => { it('should call the global fetch with the global receiver when fetchFn is not injected', async () => { const globalFetchMock = vi.fn(function (this: typeof globalThis, _req: RequestInfo | URL, _init?: RequestInit) { diff --git a/spx-gui/src/apis/common/client.ts b/spx-gui/src/apis/common/client.ts index 14c0b0e9f6..1880310d8a 100644 --- a/spx-gui/src/apis/common/client.ts +++ b/spx-gui/src/apis/common/client.ts @@ -103,6 +103,27 @@ export class Client { return new Request(url, { method, headers, body }) } + /** Prepare request object, encoding payload as application/x-www-form-urlencoded */ + private async prepareFormRequest(path: string, payload: QueryParams, options?: RequestOptions): Promise { + const traceData = Sentry.getTraceData() + const sentryTraceHeader = traceData['sentry-trace'] + const sentryBaggageHeader = traceData['baggage'] + const url = this.baseUrl + path + const method = options?.method ?? 'POST' + const body = new URLSearchParams() + Object.entries(payload).forEach(([key, value]) => { + if (value != null) body.append(key, `${value}`) + }) + const token = await this.tokenProvider() + options?.signal?.throwIfAborted() + const headers = options?.headers ?? new Headers() + headers.set('Content-Type', 'application/x-www-form-urlencoded') + if (token != null) 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: body.toString() }) + } + /** Perform request object and handle errors */ private async doRequest(req: Request, options?: RequestOptions): Promise { const timeout = options?.timeout ?? this.defaultTimeout @@ -134,6 +155,17 @@ export class Client { return resp.json() } + /** Do a form request, parsing response body as JSON when present */ + private async requestForm(path: string, payload: QueryParams, options?: RequestOptions): Promise { + const req = await this.prepareFormRequest(path, payload, options) + const resp = await this.doRequest(req, options) + if (resp.status === 204) return null + const contentType = resp.headers.get('Content-Type') ?? '' + if (contentType.includes('application/json')) return resp.json() + const text = await resp.text() + return text === '' ? null : text + } + private async requestBinary(path: string, payload: FormData, options?: RequestOptions) { const traceData = Sentry.getTraceData() const sentryTraceHeader = traceData['sentry-trace'] @@ -197,6 +229,10 @@ export class Client { return this.requestJSON(path, payload, { ...options, method: 'POST' }) } + postForm(path: string, payload: QueryParams, options?: Omit) { + return this.requestForm(path, payload, { ...options, method: 'POST' }) + } + postBinary(path: string, payload: FormData, options?: Omit) { return this.requestBinary(path, payload, { ...options, method: 'POST' }) } diff --git a/spx-gui/src/components/navbar/NavbarProfile.vue b/spx-gui/src/components/navbar/NavbarProfile.vue index b0233221e0..b3bd868de5 100644 --- a/spx-gui/src/components/navbar/NavbarProfile.vue +++ b/spx-gui/src/components/navbar/NavbarProfile.vue @@ -77,7 +77,7 @@ import { useNetwork } from '@/utils/network' import { useMessageHandle } from '@/utils/exception' import { getUserPageRoute } from '@/router' import { AssetType } from '@/apis/asset' -import { initiateSignIn, signOut, useSignedInStateQuery } from '@/stores/user' +import { initiateSignIn, revokeTokens, useSignedInStateQuery } from '@/stores/user' import { useAvatarUrl } from '@/stores/user/avatar' import { UIButton, UIDropdown, UIMenu, UIMenuGroup, UIMenuItem, UITooltip } from '@/components/ui' import { useAssetLibraryManagement } from '@/components/asset' @@ -117,8 +117,8 @@ const manageCourses = useMessageHandle(manageCoursesFn).fn const manageCourseSeriesFn = useCourseSeriesManagement() const manageCourseSeries = useMessageHandle(manageCourseSeriesFn).fn -function handleSignOut() { - signOut() +async function handleSignOut() { + await revokeTokens() router.go(0) // Reload the page to trigger navigation guards. } diff --git a/spx-gui/src/pages/sign-in/callback.vue b/spx-gui/src/pages/sign-in/callback.vue index 6b35c66c0a..a0524ddd7c 100644 --- a/spx-gui/src/pages/sign-in/callback.vue +++ b/spx-gui/src/pages/sign-in/callback.vue @@ -5,19 +5,15 @@ + diff --git a/spx-gui/package.json b/spx-gui/package.json index fe99be8625..324ec43528 100644 --- a/spx-gui/package.json +++ b/spx-gui/package.json @@ -9,8 +9,8 @@ "dev": "vite", "build": "./build-tutorial-books.sh && vue-tsc --build --force && NODE_ENV=production vite build --mode ${NODE_ENV:-production}", "preview": "vite preview", - "dev:account-web": "vite --config vite.config.account-web.ts --port 5174", - "build:account-web": "vue-tsc --build --force && NODE_ENV=production vite build --config vite.config.account-web.ts --mode ${NODE_ENV:-production}", + "dev:account": "vite --config vite.config.account-web.ts --port 5174", + "build:account": "vue-tsc --build --force && NODE_ENV=production vite build --config vite.config.account-web.ts --mode ${NODE_ENV:-production}", "type-check": "vue-tsc --build --force", "format-check": "prettier --check ./src", "format": "prettier --write ./src", diff --git a/spx-gui/src/apis/account-oauth.ts b/spx-gui/src/apis/account-oauth.ts index 05322c686a..c7cfad6bc3 100644 --- a/spx-gui/src/apis/account-oauth.ts +++ b/spx-gui/src/apis/account-oauth.ts @@ -1,30 +1,27 @@ +/** + * API client for the main xbuilder.com OAuth flow. + * + * These are used by the main site's sign-in flow (stores/user/signed-in.ts + * and pages/sign-in/callback.vue) to interact with the Account OAuth + * endpoints — pushed authorization request (PAR), authorization code + * exchange, token refresh, and token revocation. + * + * This file is for the main site's OAuth client. For the Account Web + * sign-in page session API, see account-session.ts. + */ + import { Client } from '@/apis/common/client' import { accountOAuthClientId, apiBaseUrl } from '@/utils/env' const client = new Client() -export interface PushedAuthorizationRequestResponse { - request_uri: string - expires_in?: number | null -} - -export interface AccountOAuthTokenResponse { - access_token: string - expires_in: number - refresh_token?: string | null - token_type?: string - scope?: string -} - function getAccountOAuthRedirectUri() { return `${window.location.origin}/sign-in/callback` } -export function getAccountOAuthAuthorizeUrl(requestUri: string) { - const url = new URL('/account/oauth/authorize', new URL(apiBaseUrl, window.location.origin)) - url.searchParams.set('client_id', accountOAuthClientId) - url.searchParams.set('request_uri', requestUri) - return url.toString() +export interface PushedAuthorizationRequestResponse { + request_uri: string + expires_in?: number | null } export function createPushedAuthorizationRequest(params: { state: string; codeChallenge: string }) { @@ -39,6 +36,21 @@ export function createPushedAuthorizationRequest(params: { state: string; codeCh }) as Promise } +export function getAccountOAuthAuthorizeUrl(requestUri: string) { + const url = new URL('/account/oauth/authorize', new URL(apiBaseUrl, window.location.origin)) + url.searchParams.set('client_id', accountOAuthClientId) + url.searchParams.set('request_uri', requestUri) + return url.toString() +} + +export interface AccountOAuthTokenResponse { + access_token: string + expires_in: number + refresh_token?: string | null + token_type?: string + scope?: string +} + export function exchangeAuthorizationCode(code: string, codeVerifier: string) { return client.postForm('/account/oauth/token', { grant_type: 'authorization_code', diff --git a/spx-gui/src/account-web/apis/account.ts b/spx-gui/src/apis/account-session.ts similarity index 77% rename from spx-gui/src/account-web/apis/account.ts rename to spx-gui/src/apis/account-session.ts index e3a71acea6..0a743d8242 100644 --- a/spx-gui/src/account-web/apis/account.ts +++ b/spx-gui/src/apis/account-session.ts @@ -1,6 +1,20 @@ +/** + * API client for Account Web sign-in page session management. + * + * These are used by the sign-in page (apps/account/) to interact with + * the Account API for session operations — checking current session, + * signing in with password, listing identity providers, and clearing + * the session on account switch. + * + * This file is specific to the Account Web sign-in flow and is not used by + * the main xbuilder.com OAuth client logic (see account-oauth.ts for that). + */ + import { Client } from '@/apis/common/client' import { ApiException, ApiExceptionCode } from '@/apis/common/exception' -import type { OAuthContext } from '../utils/oauth' +import type { OAuthContext } from '@/utils/account/sign-in' + +const client = new Client({ baseUrl: '/api' }) export interface AccountUser { id: string @@ -23,22 +37,6 @@ export interface CurrentAccountSession { ipAddress?: string | null } -export interface IdentityProvider { - name: string - displayName: string - enabled: boolean -} - -interface IdentityProviderListResponse { - data: IdentityProvider[] -} - -function filterEnabledIdentityProviders(providers: IdentityProvider[]): IdentityProvider[] { - return providers.filter((provider) => provider.enabled) -} - -const client = new Client({ baseUrl: '/api' }) - function isUnauthorizedError(error: unknown) { return ( (error instanceof ApiException && error.code === ApiExceptionCode.errorUnauthorized) || @@ -59,6 +57,16 @@ export async function deleteCurrentAccountSession(): Promise { await client.delete('/session') } +export interface IdentityProvider { + name: string + displayName: string + enabled: boolean +} + +interface IdentityProviderListResponse { + data: IdentityProvider[] +} + export async function getIdentityProviders(context?: OAuthContext): Promise { const response = (await client.get( '/identity-providers', @@ -69,7 +77,7 @@ export async function getIdentityProviders(context?: OAuthContext): Promise provider.enabled) } export interface PasswordSignInPayload { diff --git a/spx-gui/src/account-web/App.vue b/spx-gui/src/apps/account/App.vue similarity index 100% rename from spx-gui/src/account-web/App.vue rename to spx-gui/src/apps/account/App.vue diff --git a/spx-gui/src/account-web/main.ts b/spx-gui/src/apps/account/main.ts similarity index 60% rename from spx-gui/src/account-web/main.ts rename to spx-gui/src/apps/account/main.ts index 390846298e..51e90a8f16 100644 --- a/spx-gui/src/account-web/main.ts +++ b/spx-gui/src/apps/account/main.ts @@ -2,6 +2,11 @@ import '@/polyfills' import '@/app.css' import { createApp, watchEffect } from 'vue' +import dayjs from 'dayjs' +import localizedFormat from 'dayjs/plugin/localizedFormat' +import relativeTime from 'dayjs/plugin/relativeTime' +import utc from 'dayjs/plugin/utc' +import timezone from 'dayjs/plugin/timezone' import { createI18n, normalizeLang } from '@/utils/i18n' import { defaultLang } from '@/utils/env' @@ -9,6 +14,14 @@ import { defaultLang } from '@/utils/env' import App from './App.vue' import router from './router' +function initDayjs() { + dayjs.extend(localizedFormat) + dayjs.extend(relativeTime) + dayjs.extend(utc) + dayjs.extend(timezone) +} +initDayjs() + const langLocalStorageKey = 'spx-gui-language' const lang = normalizeLang(localStorage.getItem(langLocalStorageKey) ?? defaultLang) const i18n = createI18n({ lang }) diff --git a/spx-gui/src/account-web/pages/sign-in.vue b/spx-gui/src/apps/account/pages/sign-in.vue similarity index 75% rename from spx-gui/src/account-web/pages/sign-in.vue rename to spx-gui/src/apps/account/pages/sign-in.vue index 5bc320a3de..645cd4cdeb 100644 --- a/spx-gui/src/account-web/pages/sign-in.vue +++ b/spx-gui/src/apps/account/pages/sign-in.vue @@ -18,10 +18,10 @@ import { UIError } from '@/components/ui' import { usePageTitle } from '@/utils/utils' import { isMobile } from '@/utils/ua' -import { parseOAuthContext } from '@/account-web/utils/oauth' +import { parseOAuthContext } from '@/utils/account/sign-in' -import XBuilderLoginPageMobile from '@/account-web/components/login/XBuilderLoginPageMobile.vue' -import XBuilderLoginPagePc from '@/account-web/components/login/XBuilderLoginPagePc.vue' +import XBuilderLoginPageMobile from '@/components/sign-in/XBuilderLoginPageMobile/XBuilderLoginPageMobile.vue' +import XBuilderLoginPagePc from '@/components/sign-in/XBuilderLoginPagePc/XBuilderLoginPagePc.vue' const mobile = isMobile() const authContext = parseOAuthContext() diff --git a/spx-gui/src/account-web/router.ts b/spx-gui/src/apps/account/router.ts similarity index 88% rename from spx-gui/src/account-web/router.ts rename to spx-gui/src/apps/account/router.ts index 62cf4269e7..422f51e54d 100644 --- a/spx-gui/src/account-web/router.ts +++ b/spx-gui/src/apps/account/router.ts @@ -11,7 +11,7 @@ const routes: Array = [ }, { path: accountWebRoutePaths.signIn, - component: () => import('@/account-web/pages/sign-in.vue') + component: () => import('@/apps/account/pages/sign-in.vue') }, { path: '/:pathMatch(.*)*', diff --git a/spx-gui/src/account-web/components/login/AccountSessionSection.vue b/spx-gui/src/components/sign-in/AccountSessionSection.vue similarity index 91% rename from spx-gui/src/account-web/components/login/AccountSessionSection.vue rename to spx-gui/src/components/sign-in/AccountSessionSection.vue index e3fd931e8b..6345f13d0e 100644 --- a/spx-gui/src/account-web/components/login/AccountSessionSection.vue +++ b/spx-gui/src/components/sign-in/AccountSessionSection.vue @@ -28,8 +28,8 @@ + diff --git a/spx-gui/src/apps/account/main.ts b/spx-gui/src/apps/account/main.ts index 51e90a8f16..0848b9aa51 100644 --- a/spx-gui/src/apps/account/main.ts +++ b/spx-gui/src/apps/account/main.ts @@ -14,6 +14,11 @@ import { defaultLang } from '@/utils/env' import App from './App.vue' import router from './router' +// TODO: Consider using @/setup's setup() and configureApp() for initialization +// once we determine which parts of the shared bootstrap are needed. +// Currently dayjs, i18n, and router are set up locally because Account Web +// does not need VueKonva, Sentry, VueQuery, VueRadar, or other xbuilder plugins. + function initDayjs() { dayjs.extend(localizedFormat) dayjs.extend(relativeTime) diff --git a/spx-gui/src/App.vue b/spx-gui/src/apps/xbuilder/App.vue similarity index 97% rename from spx-gui/src/App.vue rename to spx-gui/src/apps/xbuilder/App.vue index 7fb441d4b8..2b598411f1 100644 --- a/spx-gui/src/App.vue +++ b/spx-gui/src/apps/xbuilder/App.vue @@ -30,7 +30,7 @@ import { SpotlightUI } from '@/utils/spotlight' import { useI18n } from '@/utils/i18n' import { useInstallRouteLoading } from '@/utils/route-loading' import { isMobile } from '@/utils/ua' -import { getUIConfig } from './setup' +import { getUIConfig } from '@/setup' const MobileReminder = defineAsyncComponent(() => import('@/components/app/device-check/MobileReminder.vue')) diff --git a/spx-gui/src/apps/xbuilder/main.ts b/spx-gui/src/apps/xbuilder/main.ts new file mode 100644 index 0000000000..26e7cce983 --- /dev/null +++ b/spx-gui/src/apps/xbuilder/main.ts @@ -0,0 +1,12 @@ +import '@/polyfills' +import '@/app.css' +import { createApp } from 'vue' +import { setup, configureApp } from '@/setup' +import { initRouter } from './router' +import App from './App.vue' + +setup() +const app = createApp(App) +const router = initRouter(app) +configureApp(app, router) +app.mount('#app') diff --git a/spx-gui/src/pages/404/error.png b/spx-gui/src/apps/xbuilder/pages/404/error.png similarity index 100% rename from spx-gui/src/pages/404/error.png rename to spx-gui/src/apps/xbuilder/pages/404/error.png diff --git a/spx-gui/src/pages/404/index.vue b/spx-gui/src/apps/xbuilder/pages/404/index.vue similarity index 100% rename from spx-gui/src/pages/404/index.vue rename to spx-gui/src/apps/xbuilder/pages/404/index.vue diff --git a/spx-gui/src/pages/community/explore.vue b/spx-gui/src/apps/xbuilder/pages/community/explore.vue similarity index 100% rename from spx-gui/src/pages/community/explore.vue rename to spx-gui/src/apps/xbuilder/pages/community/explore.vue diff --git a/spx-gui/src/pages/community/home.vue b/spx-gui/src/apps/xbuilder/pages/community/home.vue similarity index 98% rename from spx-gui/src/pages/community/home.vue rename to spx-gui/src/apps/xbuilder/pages/community/home.vue index e1229aa813..f8168c1d8f 100644 --- a/spx-gui/src/pages/community/home.vue +++ b/spx-gui/src/apps/xbuilder/pages/community/home.vue @@ -120,7 +120,7 @@ import { computed } from 'vue' import { useQuery } from '@/utils/query' import { usePageTitle } from '@/utils/utils' import { ExploreOrder, ProjectType, exploreProjects, listSignedInUserProjects } from '@/apis/project' -import { getExploreRoute, getUserPageRoute } from '@/router' +import { getExploreRoute, getUserPageRoute } from '../../router' import { isSignedIn, useSignedInUser } from '@/stores/user' import { useResponsive } from '@/components/ui' import ProjectsSection from '@/components/community/ProjectsSection.vue' diff --git a/spx-gui/src/pages/community/index.vue b/spx-gui/src/apps/xbuilder/pages/community/index.vue similarity index 100% rename from spx-gui/src/pages/community/index.vue rename to spx-gui/src/apps/xbuilder/pages/community/index.vue diff --git a/spx-gui/src/pages/community/project.vue b/spx-gui/src/apps/xbuilder/pages/community/project.vue similarity index 99% rename from spx-gui/src/pages/community/project.vue rename to spx-gui/src/apps/xbuilder/pages/community/project.vue index a8ee069f7a..7774c0d810 100644 --- a/spx-gui/src/pages/community/project.vue +++ b/spx-gui/src/apps/xbuilder/pages/community/project.vue @@ -13,7 +13,7 @@ import { ProjectType, listProjects, recordProjectView, stringifyRemixSource, Vis import { listProjectReleases } from '@/apis/project-release' import { SpxProject, type CloudProject } from '@/models/spx/project' import { useSignedInUser, useUser, isSignedIn, initiateSignIn } from '@/stores/user' -import { getOwnProjectEditorRoute, getProjectEditorRoute, getProjectPageRoute, getUserPageRoute } from '@/router' +import { getOwnProjectEditorRoute, getProjectEditorRoute, getProjectPageRoute, getUserPageRoute } from '../../router' import { UIIcon, UILoading, diff --git a/spx-gui/src/pages/community/search.vue b/spx-gui/src/apps/xbuilder/pages/community/search.vue similarity index 100% rename from spx-gui/src/pages/community/search.vue rename to spx-gui/src/apps/xbuilder/pages/community/search.vue diff --git a/spx-gui/src/pages/community/user/followers.vue b/spx-gui/src/apps/xbuilder/pages/community/user/followers.vue similarity index 100% rename from spx-gui/src/pages/community/user/followers.vue rename to spx-gui/src/apps/xbuilder/pages/community/user/followers.vue diff --git a/spx-gui/src/pages/community/user/following.vue b/spx-gui/src/apps/xbuilder/pages/community/user/following.vue similarity index 100% rename from spx-gui/src/pages/community/user/following.vue rename to spx-gui/src/apps/xbuilder/pages/community/user/following.vue diff --git a/spx-gui/src/pages/community/user/index.vue b/spx-gui/src/apps/xbuilder/pages/community/user/index.vue similarity index 100% rename from spx-gui/src/pages/community/user/index.vue rename to spx-gui/src/apps/xbuilder/pages/community/user/index.vue diff --git a/spx-gui/src/pages/community/user/likes.vue b/spx-gui/src/apps/xbuilder/pages/community/user/likes.vue similarity index 100% rename from spx-gui/src/pages/community/user/likes.vue rename to spx-gui/src/apps/xbuilder/pages/community/user/likes.vue diff --git a/spx-gui/src/pages/community/user/overview.vue b/spx-gui/src/apps/xbuilder/pages/community/user/overview.vue similarity index 98% rename from spx-gui/src/pages/community/user/overview.vue rename to spx-gui/src/apps/xbuilder/pages/community/user/overview.vue index 5ec6cfcbaf..006c9b7317 100644 --- a/spx-gui/src/pages/community/user/overview.vue +++ b/spx-gui/src/apps/xbuilder/pages/community/user/overview.vue @@ -1,6 +1,6 @@