feat: enforce per-user module access in Makelore
This commit is contained in:
37
src/App.tsx
37
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<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;
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
},
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user