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

@@ -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" />