diff --git a/src/App.vue b/src/App.vue index 1440c1cf..b47b5898 100644 --- a/src/App.vue +++ b/src/App.vue @@ -24,11 +24,18 @@ :store-filter="storeFilter" :sidebar-entries="sidebarEntries" :entry-counts="entryCounts" + :current-user="currentUser" @toggle-theme="toggleTheme" @select-tab="selectTab" @close="isSidebarOpen = false" @list="handleList" @update="handleUpdate" + @request-login="showLoginModal = true" + @open-user-management="openUserManagement" + @open-favorites="openFavoriteManagement" + @open-forum="openExternalUrl(FLARUM_BASE_URL)" + @edit-profile="openExternalUrl(FLARUM_SETTINGS_URL)" + @logout="logout" /> @@ -169,6 +176,23 @@ + + + + @@ -191,8 +215,13 @@ import UninstallConfirmModal from "./components/UninstallConfirmModal.vue"; import ApmInstallConfirmModal from "./components/ApmInstallConfirmModal.vue"; import AboutModal from "./components/AboutModal.vue"; import SettingsModal from "./components/SettingsModal.vue"; +import LoginModal from "./components/LoginModal.vue"; +import LoginPromptModal from "./components/LoginPromptModal.vue"; import { APM_STORE_BASE_URL, + FLARUM_BASE_URL, + FLARUM_REGISTER_URL, + FLARUM_SETTINGS_URL, currentApp, currentAppSparkInstalled, currentAppApmInstalled, @@ -211,6 +240,14 @@ import { rankAppsBySearch, } from "./modules/appSearch"; import { handleInstall, handleRetry } from "./modules/processInstall"; +import { exchangeFlarumToken } from "./modules/backendApi"; +import { requestFlarumToken } from "./modules/flarumAuth"; +import { + currentUser, + isLoggedIn, + logout, + setAuthSession, +} from "./global/authState"; import { getAllowedInstalledOrigin, getEffectiveStoreFilter, @@ -226,6 +263,7 @@ import type { CategoryInfo, HomeLink, HomeList, + FlarumLoginPayload, SidebarEntry, UpdateCenterItem, } from "./global/typedefinition"; @@ -289,6 +327,11 @@ const showUninstallModal = ref(false); const uninstallTargetApp: Ref = ref(null); const showAboutModal = ref(false); const showSettingsModal = ref(false); +const showLoginModal = ref(false); +const loginLoading = ref(false); +const loginError = ref(""); +const showLoginPrompt = ref(false); +const loginPromptMessage = ref("请登录星火账号后继续操作。"); const sparkAvailable = ref(false); const apmAvailable = ref(false); const sidebarEntries: Ref = ref([]); @@ -1062,6 +1105,51 @@ const closeSettingsModal = () => { showSettingsModal.value = false; }; +const openExternalUrl = (url: string) => { + window.open(url, "_blank", "noopener,noreferrer"); +}; + +const requireLogin = (message: string): boolean => { + if (isLoggedIn.value) return true; + loginPromptMessage.value = message; + showLoginPrompt.value = true; + return false; +}; + +const openLoginFromPrompt = () => { + showLoginPrompt.value = false; + showLoginModal.value = true; +}; + +const handleFlarumLogin = async (payload: FlarumLoginPayload) => { + loginLoading.value = true; + loginError.value = ""; + + try { + const flarumToken = await requestFlarumToken(payload); + const session = await exchangeFlarumToken({ + flarumUserId: flarumToken.userId, + flarumToken: flarumToken.token, + }); + setAuthSession(session); + showLoginModal.value = false; + } catch (error: unknown) { + loginError.value = (error as Error)?.message || "登录失败,请稍后重试"; + } finally { + loginLoading.value = false; + } +}; + +const openUserManagement = () => { + if (!requireLogin("请登录后查看和管理账号信息。")) return; + showLoginPrompt.value = false; +}; + +const openFavoriteManagement = () => { + if (!requireLogin("请登录后查看我的收藏。")) return; + showLoginPrompt.value = false; +}; + // TODO: 目前 APM 商店不能暂停下载 const pauseDownload = (id: DownloadItem) => { const download = downloads.value.find((d) => d.id === id.id); @@ -1195,7 +1283,7 @@ const loadSidebarConfig = async () => { try { const response = await axiosInstance.get(path); const data = response.data; - const entries = Array.isArray(data) ? data : (data.entries || []); + const entries = Array.isArray(data) ? data : data.entries || []; for (const entry of entries) { if (entry.id && entry.name) { @@ -1249,9 +1337,7 @@ const loadApps = async (onFirstBatch?: () => void) => { const path = `/${finalArch}/${category}/applist.json`; logger.info(`加载分类: ${category} (来源: ${mode})`); - const categoryApps = await fetchWithRetry( - path, - ); + const categoryApps = await fetchWithRetry(path); const normalizedApps = (categoryApps || []).map((appJson) => ({ name: appJson.Name, diff --git a/src/__tests__/unit/AppSidebar.account.test.ts b/src/__tests__/unit/AppSidebar.account.test.ts new file mode 100644 index 00000000..a1d79f34 --- /dev/null +++ b/src/__tests__/unit/AppSidebar.account.test.ts @@ -0,0 +1,48 @@ +import { fireEvent, render, screen } from "@testing-library/vue"; +import { describe, expect, it } from "vitest"; + +import AppSidebar from "@/components/AppSidebar.vue"; +import type { SparkUser } from "@/global/typedefinition"; + +const baseProps = { + activeTab: "all", + categoryCounts: { all: 0 }, + themeMode: "auto" as const, + storeFilter: "both" as const, + sparkAvailable: true, + apmAvailable: true, + sidebarEntries: [], + entryCounts: {}, +}; + +const user: SparkUser = { + id: 1, + flarumUserId: "123", + username: "momen", + displayName: "Momen", + avatarUrl: "https://bbs.spark-app.store/avatar.png", + forumLevel: "管理员", + forumGroups: ["管理员"], +}; + +describe("AppSidebar account entry", () => { + it("prompts login when anonymous", async () => { + const rendered = render(AppSidebar, { + props: { ...baseProps, currentUser: null }, + }); + + await fireEvent.click(screen.getByRole("button", { name: /登录 \/ 注册/ })); + + expect(rendered.emitted("request-login")).toHaveLength(1); + }); + + it("opens quick menu for logged-in users", async () => { + render(AppSidebar, { props: { ...baseProps, currentUser: user } }); + + await fireEvent.click(screen.getByRole("button", { name: /Momen/ })); + + expect(screen.getByText("用户管理")).toBeTruthy(); + expect(screen.getByText("我的收藏")).toBeTruthy(); + expect(screen.getByText("退出登录")).toBeTruthy(); + }); +}); diff --git a/src/__tests__/unit/AppSidebar.test.ts b/src/__tests__/unit/AppSidebar.test.ts index f861add1..1a25e3bc 100644 --- a/src/__tests__/unit/AppSidebar.test.ts +++ b/src/__tests__/unit/AppSidebar.test.ts @@ -16,6 +16,7 @@ const renderSidebar = ( apmAvailable: true, sidebarEntries: [], entryCounts: {}, + currentUser: null, ...overrides, }, }); diff --git a/src/__tests__/unit/LoginModal.test.ts b/src/__tests__/unit/LoginModal.test.ts new file mode 100644 index 00000000..d4cc9ddb --- /dev/null +++ b/src/__tests__/unit/LoginModal.test.ts @@ -0,0 +1,23 @@ +import { fireEvent, render, screen } from "@testing-library/vue"; +import { describe, expect, it } from "vitest"; + +import LoginModal from "@/components/LoginModal.vue"; + +describe("LoginModal", () => { + it("emits login credentials and register request", async () => { + const rendered = render(LoginModal, { + props: { show: true, loading: false, error: "" }, + }); + + await fireEvent.update(screen.getByLabelText("论坛账号"), "momen"); + await fireEvent.update(screen.getByLabelText("论坛密码"), "secret"); + await fireEvent.click(screen.getByRole("button", { name: "登录" })); + await fireEvent.click(screen.getByRole("button", { name: "注册账号" })); + + expect(rendered.emitted("login")?.[0]?.[0]).toEqual({ + identification: "momen", + password: "secret", + }); + expect(rendered.emitted("register")).toHaveLength(1); + }); +}); diff --git a/src/__tests__/unit/authState.test.ts b/src/__tests__/unit/authState.test.ts new file mode 100644 index 00000000..e1cc3315 --- /dev/null +++ b/src/__tests__/unit/authState.test.ts @@ -0,0 +1,40 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +describe("authState", () => { + beforeEach(() => { + vi.resetModules(); + localStorage.clear(); + }); + + it("persists and clears a backend session", async () => { + const { authSession, currentUser, isLoggedIn, setAuthSession, logout } = + await import("@/global/authState"); + + setAuthSession({ + accessToken: "jwt", + tokenType: "bearer", + user: { + id: 1, + flarumUserId: "123", + username: "momen", + displayName: "Momen", + avatarUrl: "https://bbs.spark-app.store/avatar.png", + forumLevel: "管理员", + forumGroups: ["管理员"], + }, + }); + + expect(authSession.value?.accessToken).toBe("jwt"); + expect(currentUser.value?.displayName).toBe("Momen"); + expect(isLoggedIn.value).toBe(true); + expect( + JSON.parse(localStorage.getItem("spark-store-auth") || "{}").accessToken, + ).toBe("jwt"); + + logout(); + + expect(authSession.value).toBeNull(); + expect(isLoggedIn.value).toBe(false); + expect(localStorage.getItem("spark-store-auth")).toBeNull(); + }); +}); diff --git a/src/components/AccountQuickMenu.vue b/src/components/AccountQuickMenu.vue new file mode 100644 index 00000000..b7dc12e2 --- /dev/null +++ b/src/components/AccountQuickMenu.vue @@ -0,0 +1,78 @@ + + + + + diff --git a/src/components/AppSidebar.vue b/src/components/AppSidebar.vue index 2ec3369d..d04e6ed1 100644 --- a/src/components/AppSidebar.vue +++ b/src/components/AppSidebar.vue @@ -1,21 +1,44 @@ diff --git a/src/components/LoginPromptModal.vue b/src/components/LoginPromptModal.vue new file mode 100644 index 00000000..bc656ac2 --- /dev/null +++ b/src/components/LoginPromptModal.vue @@ -0,0 +1,58 @@ + + + diff --git a/src/global/authState.ts b/src/global/authState.ts new file mode 100644 index 00000000..1751002d --- /dev/null +++ b/src/global/authState.ts @@ -0,0 +1,62 @@ +import { computed, ref } from "vue"; + +import { setBackendToken } from "@/modules/backendApi"; +import type { AuthSession, SparkUser } from "./typedefinition"; + +const AUTH_STORAGE_KEY = "spark-store-auth"; + +const isSparkUser = (value: unknown): value is SparkUser => { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const user = value as Record; + return ( + typeof user.id === "number" && + typeof user.flarumUserId === "string" && + typeof user.username === "string" && + typeof user.displayName === "string" && + typeof user.avatarUrl === "string" && + typeof user.forumLevel === "string" && + Array.isArray(user.forumGroups) && + user.forumGroups.every((group) => typeof group === "string") + ); +}; + +const isAuthSession = (value: unknown): value is AuthSession => { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const session = value as Record; + return ( + typeof session.accessToken === "string" && + session.accessToken.length > 0 && + session.tokenType === "bearer" && + isSparkUser(session.user) + ); +}; + +const loadStoredSession = (): AuthSession | null => { + const raw = localStorage.getItem(AUTH_STORAGE_KEY); + if (!raw) return null; + + try { + const parsed: unknown = JSON.parse(raw); + return isAuthSession(parsed) ? parsed : null; + } catch { + return null; + } +}; + +export const authSession = ref(loadStoredSession()); +export const currentUser = computed(() => authSession.value?.user ?? null); +export const isLoggedIn = computed(() => authSession.value !== null); + +setBackendToken(authSession.value?.accessToken ?? null); + +export const setAuthSession = (session: AuthSession): void => { + authSession.value = session; + localStorage.setItem(AUTH_STORAGE_KEY, JSON.stringify(session)); + setBackendToken(session.accessToken); +}; + +export const logout = (): void => { + authSession.value = null; + localStorage.removeItem(AUTH_STORAGE_KEY); + setBackendToken(null); +}; diff --git a/src/modules/backendApi.ts b/src/modules/backendApi.ts index 6811c108..e07eed6f 100644 --- a/src/modules/backendApi.ts +++ b/src/modules/backendApi.ts @@ -131,8 +131,14 @@ const toSyncedAppList = ( }); export const setBackendToken = (token: string | null): void => { - if (token) backend.defaults.headers.common.Authorization = `Bearer ${token}`; - else delete backend.defaults.headers.common.Authorization; + const backendWithOptionalDefaults = backend as typeof backend & { + defaults?: { headers?: { common?: Record } }; + }; + const commonHeaders = backendWithOptionalDefaults.defaults?.headers?.common; + if (!commonHeaders) return; + + if (token) commonHeaders.Authorization = `Bearer ${token}`; + else delete commonHeaders.Authorization; }; export const exchangeFlarumToken = async (payload: { diff --git a/src/modules/flarumAuth.ts b/src/modules/flarumAuth.ts new file mode 100644 index 00000000..aa0cde6a --- /dev/null +++ b/src/modules/flarumAuth.ts @@ -0,0 +1,28 @@ +import axios from "axios"; + +import { FLARUM_BASE_URL } from "@/global/storeConfig"; +import type { FlarumLoginPayload } from "@/global/typedefinition"; + +type FlarumTokenResponse = { + token: string; + userId: string; +}; + +const asRecord = (value: unknown): Record => { + if (value && typeof value === "object" && !Array.isArray(value)) { + return value as Record; + } + return {}; +}; + +export const requestFlarumToken = async ( + payload: FlarumLoginPayload, +): Promise => { + const response = await axios.post(`${FLARUM_BASE_URL}/api/token`, payload); + const data = asRecord(response.data); + + return { + token: String(data.token || ""), + userId: String(data.userId || data.user_id || ""), + }; +};