feat: enforce per-user module access in Makelore

This commit is contained in:
2026-08-17 08:51:15 +08:00
parent f7171a471a
commit d16922f18c
12 changed files with 457 additions and 35 deletions

View File

@@ -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<typeof useLocation>): 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 <Navigate to={AI_MODULE_SELECTION_PATH} replace />;
}
return <MainLayout />;
}
@@ -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;

View File

@@ -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<AiModuleId, ModuleAccessKey> = {
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';
}

View File

@@ -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<AiModuleId, { label: string; image: string;
export function ModuleSelection({ authRequired = true }: { authRequired?: boolean }) {
const navigate = useNavigate();
const authUser = useAuthStore((state) => 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 (
<button
key={module.id}
type="button"
data-testid={`ai-module-option-${module.id}`}
disabled={!module.enabled}
onClick={() => openModule(module.route)}
aria-label={`${content.label}${module.description}`}
disabled={!enabled}
aria-disabled={!enabled}
onClick={() => openModule(module.route, enabled)}
aria-label={`${content.label}${module.description}${enabled ? '' : '(已关闭)'}`}
className={cn(
'module-option-card module-option-card-horizontal group motion-press flex w-full min-w-0 items-stretch rounded-lg border border-transparent bg-transparent p-0 text-left shadow-none disabled:cursor-not-allowed disabled:bg-transparent disabled:text-muted-foreground/70 disabled:opacity-75',
!module.enabled && 'module-option-card-disabled',
!enabled && 'module-option-card-disabled',
)}
>
<span className="module-option-card-frame" aria-hidden="true" />
@@ -90,14 +94,18 @@ export function ModuleSelection({ authRequired = true }: { authRequired?: boolea
<img
src={content.image}
alt={content.imageAlt}
className={cn('module-option-card-image-media h-full w-full object-cover', !module.enabled && 'grayscale')}
className={cn('module-option-card-image-media h-full w-full object-cover', !enabled && 'grayscale')}
draggable="false"
/>
</div>
<div className="module-option-card-content relative min-h-0 flex-1">
<p className="module-option-card-title font-medium tracking-[-0.02em]">{content.label}</p>
<p className="module-option-card-description font-medium text-muted-foreground">{module.description}</p>
{!module.enabled ? <p className="module-option-card-status font-semibold"></p> : null}
{!enabled ? (
<p className="module-option-card-status font-semibold">
{module.enabled ? '管理员已关闭' : '暂未开放'}
</p>
) : null}
</div>
</div>
<span className="module-option-card-hit-area" aria-hidden="true" />

View File

@@ -9,6 +9,11 @@ import {
WORKS_SQUARE_ACTIVITY_SYNC_INTERVAL_MS,
WORKS_SQUARE_SESSION_IDLE_TIMEOUT_MS,
} from '../../shared/auth-session';
import {
DEFAULT_MODULE_ACCESS,
normalizeModuleAccess,
type ModuleAccess,
} from '../../shared/module-access';
export type AuthUser = {
username: string;
@@ -51,6 +56,10 @@ type MainSessionResponse = AuthActionResponse & {
session?: MainSession | null;
};
type ModuleAccessResponse = AuthActionResponse & {
moduleAccess?: unknown;
};
type RefreshSessionOptions = {
forceRefresh?: boolean;
};
@@ -69,6 +78,7 @@ type AuthState = {
/** One-release bridge for moving old Renderer-persisted refresh tokens into Main. */
legacyRefreshToken: string | null;
user: AuthUser | null;
moduleAccess: ModuleAccess;
init: () => Promise<void>;
loginWithBrowser: () => Promise<void>;
refreshSession: (options?: RefreshSessionOptions) => Promise<string | null>;
@@ -155,6 +165,7 @@ function getClearedSession() {
canRefresh: false,
legacyRefreshToken: null,
user: null,
moduleAccess: { ...DEFAULT_MODULE_ACCESS },
};
}
@@ -220,6 +231,16 @@ async function syncMainSession(session: {
}
}
async function readCurrentModuleAccess(fallback: ModuleAccess): Promise<ModuleAccess> {
try {
const response = await hostApiFetch<ModuleAccessResponse>('/api/auth/me');
if (!response.success) return fallback;
return normalizeModuleAccess(response.moduleAccess);
} catch {
return fallback;
}
}
export const useAuthStore = create<AuthState>()(
persist(
(set, get) => ({
@@ -326,7 +347,11 @@ export const useAuthStore = create<AuthState>()(
return;
}
set({ initialized: true, loading: false, error: null });
const moduleAccess = await readCurrentModuleAccess(
normalizeModuleAccess(state.moduleAccess),
);
if (!isCurrentAuthSessionEpoch(operationEpoch)) return;
set({ initialized: true, loading: false, error: null, moduleAccess });
},
loginWithBrowser: async () => {
@@ -343,6 +368,9 @@ export const useAuthStore = create<AuthState>()(
}
if (!isCurrentAuthSessionEpoch(operationEpoch)) return;
const moduleAccess = await readCurrentModuleAccess({ ...DEFAULT_MODULE_ACCESS });
if (!isCurrentAuthSessionEpoch(operationEpoch)) return;
set({
initialized: true,
loading: false,
@@ -351,6 +379,7 @@ export const useAuthStore = create<AuthState>()(
clientId: DEFAULT_CLIENT_ID,
...sessionFieldsFromMain(session),
user: createUserFromToken(response.token),
moduleAccess,
});
} catch (error) {
if (!isCurrentAuthSessionEpoch(operationEpoch)) return;
@@ -389,11 +418,15 @@ export const useAuthStore = create<AuthState>()(
throw new Error(response.error || 'Refresh failed');
}
const moduleAccess = await readCurrentModuleAccess(state.moduleAccess);
if (!isCurrentAuthSessionEpoch(operationEpoch)) return null;
set({
initialized: true,
loading: false,
error: null,
...sessionFieldsFromMain(session),
moduleAccess,
});
return session.accessToken;
} catch (error) {
@@ -589,7 +622,7 @@ export const useAuthStore = create<AuthState>()(
}),
{
name: 'niancode-auth',
version: 1,
version: 2,
migrate: (persistedState: unknown) => {
const state = persistedState && typeof persistedState === 'object'
? persistedState as Record<string, unknown>
@@ -601,6 +634,7 @@ export const useAuthStore = create<AuthState>()(
...rest,
canRefresh: state.canRefresh === true || Boolean(legacyRefreshToken),
legacyRefreshToken,
moduleAccess: normalizeModuleAccess(state.moduleAccess),
};
},
partialize: (state) => ({
@@ -613,6 +647,7 @@ export const useAuthStore = create<AuthState>()(
canRefresh: state.canRefresh,
legacyRefreshToken: state.legacyRefreshToken,
user: state.user,
moduleAccess: state.moduleAccess,
}),
},
),