diff --git a/.project-docs/30-worklog/tasks/20260817-makelore-module-access-6f2a91c4.md b/.project-docs/30-worklog/tasks/20260817-makelore-module-access-6f2a91c4.md new file mode 100644 index 0000000..fe2c40f --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260817-makelore-module-access-6f2a91c4.md @@ -0,0 +1,59 @@ +# Task: Implement MakeLore module access navigation + +## Identity + +- Task ID: 20260817-makelore-module-access-6f2a91c4 +- Mode: Feature +- Branch: codex/20260817-makelore-module-access-6f2a91c4-makelore-module-access +- Worktree: D:\Datas\OthersProjects\makelore-module-access-6f2a91c4 +- Base commit: f7171a471ab1a39380ef666b1e1be9a1f689e43f +- Owner: codex +- Status: Ready For Integration + +## Scope + +- Consume the current user's Works Square `module_access` policy through an Electron Main-owned safe projection. +- Default the four policy fields to enabled for old or partially deployed server responses. +- Grey out disabled Code, Canvas, Learning, and Robot cards, prevent chooser navigation, and block direct module routes before their workspaces initialize. +- Add focused Main route, Renderer store, chooser, and top-level route regression coverage; update README current behavior. + +## Intent And Constraints + +- Keep Works Square credentials and the raw `/api/auth/me` profile in Electron Main; Renderer receives only four booleans. +- Treat `design` as the server policy key for the existing client `painting` module id without renaming the integrated module model. +- Keep missing policy objects or keys enabled for backward compatibility; retain the last known same-session policy when refresh is temporarily unavailable. +- Prevent disabled Programming routes from initializing providers and prevent all disabled module routes from mounting `MainLayout`. +- Treat this as a client interaction/navigation gate, not as an API authorization boundary. +- Work only in the isolated linked worktree and do not modify the occupied `main` integration worktree. + +## Outcome + +- Red regression reproduced the reported symptom: a user policy with `learning: false` still rendered an enabled, navigable Learning card. +- Implemented a shared four-field policy normalizer, Main-owned `/api/auth/me` projection, startup/login/refresh policy hydration, chooser disable state, and direct-route guard. +- Bumped the persisted authentication state to schema version 2 so existing installations normalize the new policy field during upgrade; a new login falls back to all-enabled rather than inheriting another account's cached policy. +- Disabled Programming routes no longer initialize providers, and all four disabled module families redirect before `MainLayout` or module workspaces mount. + +## Verification + +- Red: `module-navigation.test.tsx` failed at `toBeDisabled()` while the static module definition remained enabled. +- Green: focused Vitest (`module-navigation`, `auth-store`, `auth-routes`, `app-module-provider-gate`) — 58 passed. +- Full Vitest — 175 files, 2036 tests passed. The first sandboxed run had one environmental `EPERM` because the test could not create worktree `.tmp`; the unrestricted rerun of the same suite passed completely. +- TypeScript `tsc --noEmit` — passed. +- Scoped ESLint for all changed TypeScript/TSX files — passed. +- Vite production build — passed (Renderer, Electron Main, and preload); existing chunk-size/dynamic-import warnings remain unchanged. +- `git diff --check` — passed. +- Electron E2E was not extended because the shared fixture deliberately bypasses authentication and cannot express a Main-owned Works `/api/auth/me` policy; the user-visible chooser and direct-route behavior are covered at rendered App/Router seams. + +## Follow-ups + +- Integration owner: merge this reviewed feature branch into the occupied `main` worktree without overwriting its existing task record. +- Release smoke: after deploying the Works Square module-access migration/API and packaging the updated client, disable each module for a real user, restart Makelore, verify the matching card is grey/non-clickable, and verify a direct deep link returns to the chooser. + +## Promotion Candidates + +- Target: canonical business rules and authentication/data-flow documentation during Integration Gate. + - Proposal: record that Makelore reads the per-user four-module policy through Electron Main at session startup, defaults missing policy fields to enabled, blocks disabled module entry and direct routing before module initialization, and does not treat this UI gate as server authorization. + - Evidence: focused Main/Store/chooser/App regressions and final verification in this task. + - Future impact: new top-level modules must define an explicit server policy mapping and route guard; backend APIs still require their own authorization. + - Semantic conflicts: none identified with the existing Main-owned Works session or four-module model. + - Human confirmation required: no; this implements the user-approved operations behavior and preserves the server's default-open compatibility contract. diff --git a/README.md b/README.md index 37eb663..729bcac 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ Makelore 是一个面向软件、视觉创作、互动学习与智能机器人 - `Makelore Robot|AI 机器`:管理机器人智能体、设备激活绑定、智能体配置与设备分配;绑定设备时默认先选择“引导配网”或“已有激活码”。在 Windows 与 macOS 的引导路径中,Makelore 可在弹窗内扫描并连接附近开放的 `Xiaozhi-*` 配网热点,失败时仍可通过系统 Wi-Fi 手动连接;后续继续复用机器人现有热点配网页面,不修改固件,也不由 Makelore 接收 Wi-Fi 密码。 - `Makelore Learning|AI 学习`:浏览和下载 Works 课程,也可以从需求、材料与多媒体选项发起后台单课生成;下载后的 frozen 课程包由内置 OpenMAIC production Stage 播放。 -应用启动默认进入 AI 模块入口选择页。入口页可在未登录状态浏览;未登录用户点击已开通模块时进入浏览器授权,授权成功后回到入口选择页,已登录用户可直接进入对应工作区。 +应用启动默认进入 AI 模块入口选择页。入口页可在未登录状态浏览;未登录用户点击已开通模块时进入浏览器授权,授权成功后回到入口选择页。已登录时,Electron Main 会从 Works Square `/api/auth/me` 读取当前账号的四模块开关并只向 Renderer 投影布尔策略;被管理员关闭的模块会在入口页置灰且无法点击,直接访问其工作区路径也会返回入口页。旧服务端未返回策略或缺少单项字段时默认开放;这个客户端门禁不替代服务端 API 授权。 作品广场、素材广场、独立发布上传和云部署页面不属于 Makelore 2.0 工作台。新建项目可选择“小游戏”“小程序”或“自定义项目”:小游戏和小程序会创建完整的平台发布模板,项目配置底部提供“一键提交审核”;Main 自动预检、安全打包并提交,构建通过后进入运营审核,审核通过即直接发布。当前 Works 项目接口没有可验证的元数据 revision/条件写或封面回收合同,因此首次创建会原子保存文字资料但暂不上传封面,已有 draft/published 只提交新版本并沿用平台现有资料与封面;恢复封面或已有资料编辑前必须先补齐服务端原子合同。自定义项目只创建工作空间,不配置默认发布方式。项目成果预览 `/deliverables` 继续保留。 diff --git a/electron/api/routes/auth.ts b/electron/api/routes/auth.ts index 2d73d58..811a4b4 100644 --- a/electron/api/routes/auth.ts +++ b/electron/api/routes/auth.ts @@ -25,6 +25,7 @@ import { } from '../../services/works-square-runtime'; import { logger } from '../../utils/logger'; import type { WorksSquareTokenPayload } from '../../services/works-square-session'; +import { normalizeModuleAccess } from '../../../shared/module-access'; type AuthClientInput = { authBase?: unknown; @@ -465,6 +466,39 @@ async function handleSessionActivity( }); } +async function handleCurrentUser(res: ServerResponse): Promise { + const accessToken = await getValidWorksSquareAccessToken({ forceRefresh: false }); + if (!accessToken) { + sendJson(res, 401, { success: false, error: '登录已过期,请重新授权。' }); + return; + } + + const response = await proxyAwareFetch(createWorksUrl('/api/auth/me').toString(), { + method: 'GET', + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }); + const payload = await readResponsePayload(response); + if (!response.ok) { + sendJson(res, response.status === 401 ? 401 : 502, { + success: false, + error: response.status === 401 + ? '登录已过期,请重新授权。' + : '暂时无法读取模块权限,请稍后重试。', + }); + return; + } + + const profile = payload && typeof payload === 'object' && !Array.isArray(payload) + ? payload as Record + : {}; + sendJson(res, 200, { + success: true, + moduleAccess: normalizeModuleAccess(profile.module_access), + }); +} + async function ensureWorksSquareSessionRestored(): Promise { if (getWorksSquareSessionRestoreStatus() === 'unavailable') { await retryWorksSquareSessionRestore(); @@ -614,6 +648,11 @@ export async function handleAuthRoutes( return true; } + if (url.pathname === '/api/auth/me' && req.method === 'GET') { + await handleCurrentUser(res); + return true; + } + if (url.pathname === '/api/auth/session/clear' && req.method === 'POST') { await handleSessionClear(res, ctx); return true; diff --git a/shared/module-access.ts b/shared/module-access.ts new file mode 100644 index 0000000..dcc77ad --- /dev/null +++ b/shared/module-access.ts @@ -0,0 +1,30 @@ +export const MODULE_ACCESS_KEYS = [ + 'programming', + 'design', + 'learning', + 'robot', +] as const; + +export type ModuleAccessKey = typeof MODULE_ACCESS_KEYS[number]; + +export type ModuleAccess = Record; + +export const DEFAULT_MODULE_ACCESS: ModuleAccess = { + programming: true, + design: true, + learning: true, + robot: true, +}; + +export function normalizeModuleAccess(value: unknown): ModuleAccess { + const record = value && typeof value === 'object' && !Array.isArray(value) + ? value as Record + : {}; + + return { + programming: typeof record.programming === 'boolean' ? record.programming : true, + design: typeof record.design === 'boolean' ? record.design : true, + learning: typeof record.learning === 'boolean' ? record.learning : true, + robot: typeof record.robot === 'boolean' ? record.robot : true, + }; +} diff --git a/src/App.tsx b/src/App.tsx index 1cf11e0..26e6875 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -31,7 +31,11 @@ import { useProviderStore } from './stores/providers'; import { useAuthStore } from './stores/auth'; import { useOpencodeStore } from './stores/opencode'; import { useProjectConfigStore } from './stores/project-config'; -import { AI_MODULE_SELECTION_PATH } from './lib/ai-modules'; +import { + AI_MODULE_SELECTION_PATH, + getGuardedAiModuleForPath, + isAiModuleAllowed, +} from './lib/ai-modules'; import { useUserSyncStore } from './stores/user-sync'; import { flushPendingAgentSessionSync } from '@/lib/agent-session-sync'; import { subscribeHostEvent } from '@/lib/host-events'; @@ -118,25 +122,8 @@ function getReturnPath(location: ReturnType): string { return `${location.pathname}${location.search}`; } -const PROGRAMMING_ROUTE_PREFIXES = [ - '/project-config', - '/makelore-home', - '/kangaroo', - '/subagents', - '/chat', - '/deliverables', - '/workbench', - '/opencode-chat', - '/projects', - '/sessions', - '/models', - '/settings', -] as const; - function isProgrammingRoute(pathname: string): boolean { - return PROGRAMMING_ROUTE_PREFIXES.some( - (route) => pathname === route || pathname.startsWith(`${route}/`), - ); + return getGuardedAiModuleForPath(pathname) === 'programming'; } function ProtectedLayout({ @@ -152,6 +139,8 @@ function ProtectedLayout({ }) { const location = useLocation(); const authenticated = useAuthStore((state) => state.isAuthenticated()); + const moduleAccess = useAuthStore((state) => state.moduleAccess); + const requestedModule = getGuardedAiModuleForPath(location.pathname); const allowsAnonymousImageWorkspace = imageWorkspaceLocalDevelopment && (location.pathname === '/image-canvas' || location.pathname.startsWith('/image-canvas/') @@ -176,6 +165,10 @@ function ProtectedLayout({ ); } + if (authenticated && requestedModule && !isAiModuleAllowed(requestedModule, moduleAccess)) { + return ; + } + return ; } @@ -264,6 +257,9 @@ function App() { const authInitialized = useAuthStore((state) => state.initialized); const authenticated = useAuthStore((state) => state.isAuthenticated()); const authAccessToken = useAuthStore((state) => state.accessToken); + const programmingModuleAllowed = useAuthStore( + (state) => state.moduleAccess.programming, + ); const bootstrapUserSync = useUserSyncStore((state) => state.bootstrap); const setupReady = setupComplete || skipSetupForE2E || rendererOnlyPreview; const authRequired = !skipSetupForE2E && !rendererOnlyPreview; @@ -325,9 +321,10 @@ function App() { useEffect(() => { if (rendererOnlyPreview) return; if (!setupReady) return; + if (!programmingModuleAllowed) return; if (!isProgrammingRoute(location.pathname)) return; initProviders(); - }, [initProviders, location.pathname, rendererOnlyPreview, setupReady]); + }, [initProviders, location.pathname, programmingModuleAllowed, rendererOnlyPreview, setupReady]); useEffect(() => { if (rendererOnlyPreview) return; diff --git a/src/lib/ai-modules.ts b/src/lib/ai-modules.ts index deab852..002c3e4 100644 --- a/src/lib/ai-modules.ts +++ b/src/lib/ai-modules.ts @@ -1,5 +1,6 @@ import type { LucideIcon } from 'lucide-react'; import { Bot, Code2, Paintbrush, Sigma } from 'lucide-react'; +import type { ModuleAccess, ModuleAccessKey } from '../../shared/module-access'; export const AI_MODULE_SELECTION_PATH = '/module-select'; @@ -56,7 +57,37 @@ export const aiModules: readonly AiModuleDefinition[] = [ }, ]; -export function getAiModuleForPath(pathname: string): AiModuleId { +const moduleAccessKeyById: Record = { + programming: 'programming', + painting: 'design', + learning: 'learning', + robot: 'robot', +}; + +const PROGRAMMING_ROUTE_PREFIXES = [ + '/project-config', + '/makelore-home', + '/kangaroo', + '/subagents', + '/chat', + '/deliverables', + '/workbench', + '/opencode-chat', + '/projects', + '/sessions', + '/models', + '/settings', +] as const; + +function matchesRoute(pathname: string, route: string): boolean { + return pathname === route || pathname.startsWith(`${route}/`); +} + +export function isAiModuleAllowed(moduleId: AiModuleId, access: ModuleAccess): boolean { + return access[moduleAccessKeyById[moduleId]]; +} + +export function getGuardedAiModuleForPath(pathname: string): AiModuleId | null { if (pathname === '/image-canvas' || pathname.startsWith('/image-canvas/') || pathname === '/image-prompts' @@ -69,5 +100,12 @@ export function getAiModuleForPath(pathname: string): AiModuleId { if (pathname === '/learning' || pathname.startsWith('/learning/')) { return 'learning'; } - return 'programming'; + if (PROGRAMMING_ROUTE_PREFIXES.some((route) => matchesRoute(pathname, route))) { + return 'programming'; + } + return null; +} + +export function getAiModuleForPath(pathname: string): AiModuleId { + return getGuardedAiModuleForPath(pathname) ?? 'programming'; } diff --git a/src/pages/ModuleSelection/index.tsx b/src/pages/ModuleSelection/index.tsx index e7bf228..94e8aed 100644 --- a/src/pages/ModuleSelection/index.tsx +++ b/src/pages/ModuleSelection/index.tsx @@ -7,7 +7,7 @@ import moduleRobotImage from '@/assets/module-robot.jpg'; import logoWordmarkSource from '@/assets/makelore-wordmark-source.png'; import { UserProfileDialog } from '@/components/profile/UserProfileDialog'; import { useCurrentUserProfile } from '@/hooks/use-current-user-profile'; -import { aiModules, type AiModuleId } from '@/lib/ai-modules'; +import { aiModules, isAiModuleAllowed, type AiModuleId } from '@/lib/ai-modules'; import { getAuthUserDisplayName } from '@/lib/auth-user-display'; import { cn } from '@/lib/utils'; import { useAuthStore } from '@/stores/auth'; @@ -23,6 +23,7 @@ const moduleSelectionContent: Record state.user); + const moduleAccess = useAuthStore((state) => state.moduleAccess); const authenticated = useAuthStore((state) => state.isAuthenticated()); const { userProfile, @@ -37,8 +38,8 @@ export function ModuleSelection({ authRequired = true }: { authRequired?: boolea || profileRequired || Boolean(profileSyncError && dismissedSyncError !== profileSyncError); - const openModule = (route: string | null) => { - if (!route) return; + const openModule = (route: string | null, enabled: boolean) => { + if (!route || !enabled) return; if (authRequired && !authenticated) { navigate('/login'); return; @@ -71,17 +72,20 @@ export function ModuleSelection({ authRequired = true }: { authRequired?: boolea > {aiModules.map((module) => { const content = moduleSelectionContent[module.id]; + const enabledByPolicy = isAiModuleAllowed(module.id, moduleAccess); + const enabled = module.enabled && enabledByPolicy; return (