From 9b1284dc9e3cca3dc97769322cb6e3457decf85f Mon Sep 17 00:00:00 2001 From: Konstantin Wohlwend Date: Sun, 5 Apr 2026 21:34:59 -0700 Subject: [PATCH] Fraud Protection sub-app --- .../@modal/(.)apps/[appId]/page-client.tsx | 17 +- .../[projectId]/app-enabled-guard.tsx | 3 +- .../[projectId]/apps/[appId]/page-client.tsx | 15 +- .../projects/[projectId]/apps/page-client.tsx | 5 +- .../projects/[projectId]/sidebar-layout.tsx | 26 +-- apps/dashboard/src/components/app-square.tsx | 56 +++++- .../src/components/app-store-entry.tsx | 66 +++++--- .../src/components/cmdk-commands.tsx | 159 ++++++++++++++---- .../src/lib/ai-dashboard/shared-prompt.ts | 5 +- apps/dashboard/src/lib/apps-frontend.tsx | 51 ++++-- apps/dashboard/src/lib/apps-utils.ts | 34 ++++ packages/stack-shared/src/apps/apps-config.ts | 6 + 12 files changed, 346 insertions(+), 97 deletions(-) diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/@modal/(.)apps/[appId]/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/@modal/(.)apps/[appId]/page-client.tsx index 0061981e0..6ecb0835d 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/@modal/(.)apps/[appId]/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/@modal/(.)apps/[appId]/page-client.tsx @@ -4,7 +4,8 @@ import { useAdminApp } from "@/app/(main)/(protected)/projects/[projectId]/use-a import { AppStoreEntry } from "@/components/app-store-entry"; import { useRouter } from "@/components/router"; import { Dialog, DialogContent, DialogTitle } from "@/components/ui"; -import { ALL_APPS_FRONTEND, getAppPath } from "@/lib/apps-frontend"; +import { ALL_APPS_FRONTEND, getAppPath, isSubApp } from "@/lib/apps-frontend"; +import { isAppEnabled } from "@/lib/apps-utils"; import { useUpdateConfig } from "@/lib/config-update"; import { AppId } from "@stackframe/stack-shared/dist/apps/apps-config"; import { usePathname } from "next/navigation"; @@ -20,7 +21,16 @@ export default function AppDetailsModalPageClient({ appId }: { appId: AppId }) { const config = project.useConfig(); const updateConfig = useUpdateConfig(); - const isEnabled = config.apps.installed[appId]?.enabled ?? false; + const isEnabled = isAppEnabled(config.apps.installed, appId); + const appFrontend = ALL_APPS_FRONTEND[appId]; + const appPath = getAppPath(project.id, appFrontend); + const parentAppId = isSubApp(appFrontend) ? appFrontend.parentAppId : null; + const parentAppEnabled = parentAppId == null ? false : isAppEnabled(config.apps.installed, parentAppId); + const subAppDestinationPath = parentAppId == null + ? null + : parentAppEnabled + ? appPath + : `/projects/${project.id}/apps/${parentAppId}`; // Control modal visibility based on whether we're on a modal route. // This ensures the modal only closes when navigation actually succeeds, @@ -47,9 +57,8 @@ export default function AppDetailsModalPageClient({ appId }: { appId: AppId }) { }; const handleOpen = () => { - const path = getAppPath(project.id, ALL_APPS_FRONTEND[appId]); // Navigate to the app page. Modal stays open until pathname changes. - router.replace(path); + router.replace(subAppDestinationPath ?? appPath); }; const handleOpenChange = (open: boolean) => { diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/app-enabled-guard.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/app-enabled-guard.tsx index ad98b3cc1..76521cff3 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/app-enabled-guard.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/app-enabled-guard.tsx @@ -1,6 +1,7 @@ 'use client'; import { useRouter } from "@/components/router"; +import { isAppEnabled } from "@/lib/apps-utils"; import { AppId } from "@stackframe/stack-shared/dist/apps/apps-config"; import { Typography } from "@/components/ui"; import type { ReactNode } from "react"; @@ -13,7 +14,7 @@ export function AppEnabledGuard(props: { appId: AppId, children: ReactNode }) { const adminApp = useAdminApp(); const project = adminApp.useProject(); const config = project.useConfig(); - const isEnabled = config.apps.installed[props.appId]?.enabled; + const isEnabled = isAppEnabled(config.apps.installed, props.appId); useEffect(() => { if (!isEnabled) { diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/apps/[appId]/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/apps/[appId]/page-client.tsx index 0dde34728..ec8cf349e 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/apps/[appId]/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/apps/[appId]/page-client.tsx @@ -4,7 +4,8 @@ import { useAdminApp } from "@/app/(main)/(protected)/projects/[projectId]/use-a import { AppStoreEntry } from "@/components/app-store-entry"; import { useRouter } from "@/components/router"; import { useUpdateConfig } from "@/lib/config-update"; -import { ALL_APPS_FRONTEND, getAppPath, type AppId } from "@/lib/apps-frontend"; +import { ALL_APPS_FRONTEND, getAppPath, isSubApp, type AppId } from "@/lib/apps-frontend"; +import { isAppEnabled } from "@/lib/apps-utils"; import { StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors"; import { runAsynchronouslyWithAlert } from "@stackframe/stack-shared/dist/utils/promises"; import { PageLayout } from "../../page-layout"; @@ -17,13 +18,21 @@ export default function AppDetailsPageClient({ appId }: { appId: AppId }) { const config = project.useConfig(); const updateConfig = useUpdateConfig(); - const isEnabled = config.apps.installed[appId]?.enabled ?? false; + const isEnabled = isAppEnabled(config.apps.installed, appId); const appFrontend = ALL_APPS_FRONTEND[appId]; if (!(appFrontend as any)) { throw new StackAssertionError(`App frontend not found for appId: ${appId}`, { appId }); } + const parentAppId = isSubApp(appFrontend) ? appFrontend.parentAppId : null; + const parentAppFrontend = parentAppId == null ? null : ALL_APPS_FRONTEND[parentAppId]; + const parentAppEnabled = parentAppId == null ? false : isAppEnabled(config.apps.installed, parentAppId); const appPath = getAppPath(project.id, appFrontend); + const subAppDestinationPath = parentAppFrontend == null + ? null + : parentAppEnabled + ? appPath + : `/projects/${project.id}/apps/${parentAppId}`; const handleEnable = async () => { await updateConfig({ @@ -35,7 +44,7 @@ export default function AppDetailsPageClient({ appId }: { appId: AppId }) { }; const handleOpen = () => { - router.push(appPath); + router.push(subAppDestinationPath ?? appPath); }; const handleDisable = async () => { diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/apps/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/apps/page-client.tsx index 8a691f862..9cb15c64c 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/apps/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/apps/page-client.tsx @@ -4,6 +4,7 @@ import { useAdminApp } from "@/app/(main)/(protected)/projects/[projectId]/use-a import { AppSquare } from "@/components/app-square"; import { DesignAlert, DesignCard, DesignCategoryTabs, DesignInput } from "@/components/design-components"; import { type AppId } from "@/lib/apps-frontend"; +import { getEnabledAppIds } from "@/lib/apps-utils"; import { CheckCircleIcon, MagnifyingGlassIcon, SquaresFourIcon } from "@phosphor-icons/react"; import { ALL_APPS } from "@stackframe/stack-shared/dist/apps/apps-config"; import { stringCompare } from "@stackframe/stack-shared/dist/utils/strings"; @@ -34,9 +35,7 @@ export default function PageClient() { // Get installed apps const installedApps = useMemo(() => - (Object.entries(config.apps.installed) as [string, { enabled?: boolean } | undefined][]) - .filter(([_, appConfig]) => appConfig?.enabled) - .map(([appId]) => appId as AppId), + getEnabledAppIds(config.apps.installed), [config.apps.installed] ); diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sidebar-layout.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sidebar-layout.tsx index 915d288a4..767a8a7bd 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sidebar-layout.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sidebar-layout.tsx @@ -18,7 +18,8 @@ import { TooltipTrigger, Typography, } from "@/components/ui"; -import { ALL_APPS_FRONTEND, DUMMY_ORIGIN, getAppPath, getItemPath, testAppPath, testItemPath } from "@/lib/apps-frontend"; +import { ALL_APPS_FRONTEND, DUMMY_ORIGIN, getAppPath, getItemPath, hasNavigationItems, testAppPath, testItemPath, type NavigableAppFrontend } from "@/lib/apps-frontend"; +import { getEnabledAppIds, getEnabledNavigableAppIds } from "@/lib/apps-utils"; import { useUpdateConfig } from "@/lib/config-update"; import { cn } from "@/lib/utils"; import { @@ -37,7 +38,6 @@ import { import { TooltipPortal } from "@radix-ui/react-tooltip"; import { UserButton } from "@stackframe/stack"; import { ALL_APPS, type AppId } from "@stackframe/stack-shared/dist/apps/apps-config"; -import { typedEntries } from "@stackframe/stack-shared/dist/utils/objects"; import { usePathname } from "next/navigation"; import { useCallback, useMemo, useRef, useState } from "react"; import { useAdminApp, useProjectId } from "./use-admin-app"; @@ -381,10 +381,14 @@ function AppNavItem({ // Memoize the item object to prevent NavItem re-renders const navItemData = useMemo(() => { - const items = appFrontend.navigationItems.map((navItem) => ({ + if (!hasNavigationItems(appFrontend)) { + return null; + } + const navigableFrontend: NavigableAppFrontend = appFrontend; + const items = navigableFrontend.navigationItems.map((navItem) => ({ name: navItem.displayName, - href: getItemPath(projectId, appFrontend, navItem), - match: (fullUrl: URL) => testItemPath(projectId, appFrontend, navItem, fullUrl), + href: getItemPath(projectId, navigableFrontend, navItem), + match: (fullUrl: URL) => testItemPath(projectId, navigableFrontend, navItem, fullUrl), })); return { name: app.displayName, @@ -396,6 +400,10 @@ function AppNavItem({ }; }, [app.displayName, appId, appFrontend, projectId]); + if (navItemData == null) { + return null; + } + return ( - typedEntries(config.apps.installed) - .filter(([appId, appConfig]) => appConfig?.enabled && appId in ALL_APPS) - .map(([appId]) => appId as AppId), + getEnabledNavigableAppIds(config.apps.installed), [config.apps.installed] ); @@ -608,9 +614,7 @@ function SpotlightSearchWrapper({ projectId }: { projectId: string }) { const updateConfig = useUpdateConfig(); const enabledApps = useMemo(() => - typedEntries(config.apps.installed) - .filter(([appId, appConfig]) => appConfig?.enabled && appId in ALL_APPS) - .map(([appId]) => appId as AppId), + getEnabledAppIds(config.apps.installed), [config.apps.installed] ); diff --git a/apps/dashboard/src/components/app-square.tsx b/apps/dashboard/src/components/app-square.tsx index f5d1b4455..09537c2b7 100644 --- a/apps/dashboard/src/components/app-square.tsx +++ b/apps/dashboard/src/components/app-square.tsx @@ -1,6 +1,8 @@ import { useAdminApp, useProjectId } from "@/app/(main)/(protected)/projects/[projectId]/use-admin-app"; +import { useRouter } from "@/components/router"; import { Button, cn, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui"; -import { ALL_APPS_FRONTEND, AppFrontend, getAppPath } from "@/lib/apps-frontend"; +import { ALL_APPS_FRONTEND, AppFrontend, getAppPath, isSubApp } from "@/lib/apps-frontend"; +import { isAppEnabled } from "@/lib/apps-utils"; import { useUpdateConfig } from "@/lib/config-update"; import { CheckIcon, DotsThreeVerticalIcon } from "@phosphor-icons/react"; import { ALL_APPS, AppId } from "@stackframe/stack-shared/dist/apps/apps-config"; @@ -57,9 +59,20 @@ export function AppSquare({ const project = adminApp.useProject(); const config = project.useConfig(); const updateConfig = useUpdateConfig(); + const router = useRouter(); - const isEnabled = config.apps.installed[appId]?.enabled ?? false; + const isEnabled = isAppEnabled(config.apps.installed, appId); const appDetailsPath = `/projects/${projectId}/apps/${appId}`; + const appFrontend = ALL_APPS_FRONTEND[appId]; + const parentAppId = isSubApp(appFrontend) ? appFrontend.parentAppId : null; + const parentApp = parentAppId == null ? null : ALL_APPS[parentAppId]; + const parentAppFrontend = parentAppId == null ? null : ALL_APPS_FRONTEND[parentAppId]; + const parentAppEnabled = parentAppId == null ? false : isAppEnabled(config.apps.installed, parentAppId); + const parentDestinationPath = parentAppId == null || parentAppFrontend == null + ? null + : parentAppEnabled + ? getAppPath(projectId, appFrontend) + : `/projects/${projectId}/apps/${parentAppId}`; const handleToggleEnabled = async () => { // Show warning modal for alpha/beta apps when enabling @@ -138,9 +151,15 @@ export function AppSquare({ - - {isEnabled ? 'Disable' : 'Enable'} - + {parentDestinationPath == null ? ( + + {isEnabled ? 'Disable' : 'Enable'} + + ) : ( + router.push(parentDestinationPath)} className="cursor-pointer"> + Go to {parentApp?.displayName ?? "parent app"} + + )} @@ -199,9 +218,19 @@ export function AppListItem({ const project = adminApp.useProject(); const config = project.useConfig(); - const isEnabled = config.apps.installed[appId]?.enabled ?? false; + const isEnabled = isAppEnabled(config.apps.installed, appId); const appPath = getAppPath(project.id, appFrontend); const appDetailsPath = `/projects/${project.id}/apps/${appId}`; + const router = useRouter(); + const parentAppId = isSubApp(appFrontend) ? appFrontend.parentAppId : null; + const parentApp = parentAppId == null ? null : ALL_APPS[parentAppId]; + const parentAppFrontend = parentAppId == null ? null : ALL_APPS_FRONTEND[parentAppId]; + const parentAppEnabled = parentAppId == null ? false : isAppEnabled(config.apps.installed, parentAppId); + const parentDestinationPath = parentAppId == null || parentAppFrontend == null + ? null + : parentAppEnabled + ? appPath + : `/projects/${project.id}/apps/${parentAppId}`; const handleEnable = async (e: React.MouseEvent) => { e.preventDefault(); @@ -220,7 +249,7 @@ export function AppListItem({ return ( {isEnabled ? ( + ) : parentDestinationPath != null ? ( + ) : ( + {onDisable && ( + + )} + + ) : ( + + ) + ) : ( <> +

+ This app is part of the {parentApp.displayName} app. +

- {onDisable && ( - - )} - ) : ( - )} diff --git a/apps/dashboard/src/components/cmdk-commands.tsx b/apps/dashboard/src/components/cmdk-commands.tsx index febaa2baf..a91aeaeec 100644 --- a/apps/dashboard/src/components/cmdk-commands.tsx +++ b/apps/dashboard/src/components/cmdk-commands.tsx @@ -1,8 +1,9 @@ "use client"; import { AppIcon } from "@/components/app-square"; +import { Link } from "@/components/link"; import { Badge, Button, ScrollArea } from "@/components/ui"; -import { ALL_APPS_FRONTEND, getAppPath, getItemPath } from "@/lib/apps-frontend"; +import { ALL_APPS_FRONTEND, getAppPath, getItemPath, hasNavigationItems, isSubApp, type NavigableAppFrontend } from "@/lib/apps-frontend"; import { getUninstalledAppIds } from "@/lib/apps-utils"; import { classifyClickHouseSqlVsPrompt } from "@/lib/classify-query"; import { cn } from "@/lib/utils"; @@ -37,15 +38,19 @@ export type CmdKPreviewProps = { // Available App Preview Component - shows app store page in preview panel const AvailableAppPreview = memo(function AvailableAppPreview({ appId, - projectId, onEnable, + goToParentHref, + onClose, }: { appId: AppId, - projectId: string, - onEnable: () => Promise, + onEnable?: () => Promise, + goToParentHref?: string, + onClose?: () => void, }) { const app = ALL_APPS[appId]; const appFrontend = ALL_APPS_FRONTEND[appId]; + const parentAppId = isSubApp(appFrontend) ? appFrontend.parentAppId : null; + const parentApp = parentAppId == null ? null : ALL_APPS[parentAppId]; const features = [ { icon: ShieldCheckIcon, label: "Secure" }, @@ -119,18 +124,38 @@ const AvailableAppPreview = memo(function AvailableAppPreview({ {/* Enable Button */}
- + {parentApp == null ? ( + + ) : ( + + )}
Free
+ {parentApp != null && ( +

+ This app is part of the {parentApp.displayName} app. +

+ )} {/* Stage Warning */} {app.stage !== "stable" && ( @@ -188,20 +213,29 @@ const AvailableAppPreview = memo(function AvailableAppPreview({ }); // Factory to create available app preview components -function createAvailableAppPreview(appId: AppId, projectId: string, onEnable: () => Promise): React.ComponentType { - return function AvailableAppPreviewWrapper() { - return ; +function createAvailableAppPreview( + appId: AppId, + onEnable?: () => Promise, + goToParentHref?: string +): React.ComponentType { + return function AvailableAppPreviewWrapper({ onClose }: CmdKPreviewProps) { + return ; }; } // Cache for available app preview components const availableAppPreviewCache = new Map>(); -function getOrCreateAvailableAppPreview(appId: AppId, projectId: string, onEnable: () => Promise): React.ComponentType { - const cacheKey = `${appId}:${projectId}`; +function getOrCreateAvailableAppPreview( + appId: AppId, + projectId: string, + onEnable?: () => Promise, + goToParentHref?: string +): React.ComponentType { + const cacheKey = `${appId}:${projectId}:${goToParentHref ?? "enable"}:${onEnable == null ? "readonly" : "enable"}`; let preview = availableAppPreviewCache.get(cacheKey); if (!preview) { - preview = createAvailableAppPreview(appId, projectId, onEnable); + preview = createAvailableAppPreview(appId, onEnable, goToParentHref); availableAppPreviewCache.set(cacheKey, preview); } return preview; @@ -230,10 +264,9 @@ export type CmdKCommand = { }; // Factory to create app preview components that show navigation items -function createAppPreview(appId: AppId, projectId: string): React.ComponentType { +function createAppPreview(appId: AppId, projectId: string, appFrontend: NavigableAppFrontend): React.ComponentType { // Pre-compute these outside the component since they're static per appId const app = ALL_APPS[appId]; - const appFrontend = ALL_APPS_FRONTEND[appId]; // Pre-compute nested commands since they're static const IconComponent = appFrontend.icon; @@ -273,7 +306,11 @@ function getOrCreateAppPreview(appId: AppId, projectId: string): React.Component const cacheKey = `${appId}:${projectId}`; let preview = appPreviewCache.get(cacheKey); if (!preview) { - preview = createAppPreview(appId, projectId); + const appFrontend = ALL_APPS_FRONTEND[appId]; + if (!hasNavigationItems(appFrontend)) { + throw new Error(`App ${appId} has no navigation items`); + } + preview = createAppPreview(appId, projectId, appFrontend); appPreviewCache.set(cacheKey, preview); } return preview; @@ -313,19 +350,49 @@ export function useCmdKCommands({ // Some enabled apps might not have navigation metadata yet // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition if (!app || !appFrontend) continue; + const parentAppId = isSubApp(appFrontend) ? appFrontend.parentAppId : null; + const parentApp = parentAppId == null ? null : ALL_APPS[parentAppId]; const IconComponent = appFrontend.icon; - const hasNavigationItems = appFrontend.navigationItems.length > 0; + if (!hasNavigationItems(appFrontend)) { + commands.push({ + id: `apps/${appId}`, + icon: , + label: app.displayName, + description: parentApp == null ? "Installed app" : `Part of ${parentApp.displayName}`, + keywords: [ + app.displayName.toLowerCase(), + app.subtitle.toLowerCase(), + appId, + ...app.tags, + "installed", + "app", + ...(parentApp == null ? [] : [parentApp.displayName.toLowerCase(), "sub-app"]), + ], + onAction: { type: "navigate", href: getAppPath(projectId, appFrontend) }, + preview: null, + highlightColor: "app", + }); + continue; + } - // Add the app itself as a command + const hasNestedNavigation = appFrontend.navigationItems.length > 0; commands.push({ id: `apps/${appId}`, icon: , label: app.displayName, - description: "Installed app", - keywords: [app.displayName.toLowerCase(), ...app.tags, "installed", "app"], + description: parentApp == null ? "Installed app" : `Part of ${parentApp.displayName}`, + keywords: [ + app.displayName.toLowerCase(), + app.subtitle.toLowerCase(), + appId, + ...app.tags, + "installed", + "app", + ...(parentApp == null ? [] : [parentApp.displayName.toLowerCase(), "sub-app"]), + ], onAction: { type: "navigate", href: getAppPath(projectId, appFrontend) }, - preview: hasNavigationItems ? getOrCreateAppPreview(appId, projectId) : null, + preview: hasNestedNavigation ? getOrCreateAppPreview(appId, projectId) : null, highlightColor: "app", }); } @@ -338,6 +405,15 @@ export function useCmdKCommands({ // Some apps might not have frontend metadata yet // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition if (!app || !appFrontend) continue; + const parentAppId = isSubApp(appFrontend) ? appFrontend.parentAppId : null; + const parentApp = parentAppId == null ? null : ALL_APPS[parentAppId]; + const parentAppFrontend = parentAppId == null ? null : ALL_APPS_FRONTEND[parentAppId]; + const isParentEnabled = parentAppId == null ? false : enabledApps.includes(parentAppId); + const parentDestination = parentAppId == null || parentAppFrontend == null + ? null + : isParentEnabled + ? getAppPath(projectId, appFrontend) + : `/projects/${projectId}/apps/${parentAppId}`; const IconComponent = appFrontend.icon; const hasPreview = onEnableApp !== undefined; @@ -351,15 +427,32 @@ export function useCmdKCommands({ ), label: app.displayName, - description: "Available to install", - keywords: [app.displayName.toLowerCase(), ...app.tags, "available", "install", "store", "app"], - onAction: hasPreview - ? { type: "focus" } - : { type: "navigate", href: `/projects/${projectId}/apps/${appId}` }, - preview: hasPreview - ? getOrCreateAvailableAppPreview(appId, projectId, () => onEnableApp(appId)) + description: parentApp == null ? "Available to install" : `Part of ${parentApp.displayName}`, + keywords: [ + app.displayName.toLowerCase(), + app.subtitle.toLowerCase(), + appId, + ...app.tags, + "available", + "install", + "store", + "app", + ...(parentApp == null ? [] : ["sub-app", parentApp.displayName.toLowerCase()]), + ], + onAction: parentApp == null + ? hasPreview + ? { type: "focus" } + : { type: "navigate", href: `/projects/${projectId}/apps/${appId}` } + : { type: "navigate", href: parentDestination ?? `/projects/${projectId}/apps/${appId}` }, + preview: parentApp == null && hasPreview + ? getOrCreateAvailableAppPreview( + appId, + projectId, + () => onEnableApp(appId), + undefined + ) : null, - hasVisualPreview: hasPreview, + hasVisualPreview: parentApp == null && hasPreview, }); } diff --git a/apps/dashboard/src/lib/ai-dashboard/shared-prompt.ts b/apps/dashboard/src/lib/ai-dashboard/shared-prompt.ts index 275bfeb11..18798cede 100644 --- a/apps/dashboard/src/lib/ai-dashboard/shared-prompt.ts +++ b/apps/dashboard/src/lib/ai-dashboard/shared-prompt.ts @@ -1,5 +1,5 @@ import { BUNDLED_DASHBOARD_UI_TYPES, BUNDLED_TYPE_DEFINITIONS } from "@/generated/bundled-type-definitions"; -import { ALL_APPS_FRONTEND, type AppId, getItemPath } from "@/lib/apps-frontend"; +import { ALL_APPS_FRONTEND, type AppId, getItemPath, hasNavigationItems } from "@/lib/apps-frontend"; import { buildStackAuthHeaders, type CurrentUser } from "@/lib/api-headers"; /** @@ -19,6 +19,9 @@ export function buildAvailableRoutes(enabledAppIds: AppId[]): string { // Dynamic routes from enabled apps for (const appId of enabledAppIds) { const appFrontend = ALL_APPS_FRONTEND[appId as keyof typeof ALL_APPS_FRONTEND]; + if (!hasNavigationItems(appFrontend)) { + continue; + } for (const item of appFrontend.navigationItems) { // Use a placeholder project ID — we only need the path relative to /projects/[id]/ const fullPath = getItemPath("__PROJECT__", appFrontend, item); diff --git a/apps/dashboard/src/lib/apps-frontend.tsx b/apps/dashboard/src/lib/apps-frontend.tsx index d67af0c2f..b4bb95498 100644 --- a/apps/dashboard/src/lib/apps-frontend.tsx +++ b/apps/dashboard/src/lib/apps-frontend.tsx @@ -1,5 +1,5 @@ import { Link } from "@/components/link"; -import { ChartLineIcon, ClipboardTextIcon, CreditCardIcon, EnvelopeSimpleIcon, FingerprintSimpleIcon, KeyIcon, MailboxIcon, RocketIcon, SparkleIcon, TelevisionSimpleIcon, TriangleIcon, UserGearIcon, UsersIcon, VaultIcon, WebhooksLogoIcon } from "@phosphor-icons/react"; +import { ChartLineIcon, ClipboardTextIcon, CreditCardIcon, EnvelopeSimpleIcon, FingerprintSimpleIcon, KeyIcon, MailboxIcon, RocketIcon, ShieldCheckIcon, SparkleIcon, TelevisionSimpleIcon, TriangleIcon, UserGearIcon, UsersIcon, VaultIcon, WebhooksLogoIcon } from "@phosphor-icons/react"; import { StackAdminApp } from "@stackframe/stack"; import { ALL_APPS } from "@stackframe/stack-shared/dist/apps/apps-config"; import { getRelativePart, isChildUrl } from "@stackframe/stack-shared/dist/utils/urls"; @@ -33,34 +33,55 @@ export type AppFrontend = { icon: React.FunctionComponent>, logo?: React.FunctionComponent<{}>, href: string, - matchPath?: (relativePart: string) => boolean, - getBreadcrumbItems?: (stackAdminApp: StackAdminApp, relativePart: string) => Promise, - navigationItems: AppNavigationItem[], screenshots: (string | StaticImageData)[], storeDescription: JSX.Element, -}; +} & ( + | { + navigationItems: AppNavigationItem[], + matchPath?: (relativePart: string) => boolean, + getBreadcrumbItems?: (stackAdminApp: StackAdminApp, relativePart: string) => Promise, + } + | { + parentAppId: AppId, + } +) + +export type NavigableAppFrontend = Extract; +export type SubAppFrontend = Extract; + +export function hasNavigationItems(appFrontend: AppFrontend): appFrontend is NavigableAppFrontend { + return "navigationItems" in appFrontend; +} + +export function isSubApp(appFrontend: AppFrontend): appFrontend is SubAppFrontend { + return "parentAppId" in appFrontend; +} export function getAppPath(projectId: string, appFrontend: AppFrontend) { const url = new URL(appFrontend.href, `${DUMMY_ORIGIN}/projects/${projectId}/`); return getRelativePart(url); } -export function getItemPath(projectId: string, appFrontend: AppFrontend, item: AppFrontend["navigationItems"][number]) { +export function getItemPath(projectId: string, appFrontend: NavigableAppFrontend, item: AppNavigationItem) { const url = new URL(item.href, new URL(appFrontend.href, `${DUMMY_ORIGIN}/projects/${projectId}/`) + "/"); return getRelativePart(url); } export function testAppPath(projectId: string, appFrontend: AppFrontend, fullUrl: URL) { - if (appFrontend.matchPath) return appFrontend.matchPath(getRelativePart(fullUrl)); + if ("matchPath" in appFrontend && appFrontend.matchPath) { + return appFrontend.matchPath(getRelativePart(fullUrl)); + } - for (const item of appFrontend.navigationItems) { - if (testItemPath(projectId, appFrontend, item, fullUrl)) return true; + if (hasNavigationItems(appFrontend)) { + for (const item of appFrontend.navigationItems) { + if (testItemPath(projectId, appFrontend, item, fullUrl)) return true; + } } const url = new URL(appFrontend.href, `${DUMMY_ORIGIN}/projects/${projectId}/`); return isChildUrl(url, fullUrl); } -export function testItemPath(projectId: string, appFrontend: AppFrontend, item: AppFrontend["navigationItems"][number], fullUrl: URL) { +export function testItemPath(projectId: string, appFrontend: NavigableAppFrontend, item: AppNavigationItem, fullUrl: URL) { if (item.matchPath) return item.matchPath(getRelativePart(fullUrl)); const url = new URL(getItemPath(projectId, appFrontend, item), fullUrl); @@ -84,6 +105,16 @@ export const ALL_APPS_FRONTEND = { ), }, + "fraud-protection": { + icon: ShieldCheckIcon, + href: "sign-up-rules", + parentAppId: "authentication", + screenshots: [], + storeDescription: <> +

Fraud Protection helps you protect your project from fraud and abuse.

+

Configure sign-up rules and use our built-in fraud protection features to detect bots, free trial abuse, and other fraudulent activity.

+ , + }, onboarding: { icon: ClipboardTextIcon, href: "onboarding", diff --git a/apps/dashboard/src/lib/apps-utils.ts b/apps/dashboard/src/lib/apps-utils.ts index 5d6c6e1d8..cf405df3a 100644 --- a/apps/dashboard/src/lib/apps-utils.ts +++ b/apps/dashboard/src/lib/apps-utils.ts @@ -1,7 +1,14 @@ "use client"; +import { ALL_APPS_FRONTEND, hasNavigationItems, isSubApp } from "@/lib/apps-frontend"; import { ALL_APPS, type AppId } from "@stackframe/stack-shared/dist/apps/apps-config"; +type InstalledAppConfig = { + enabled?: boolean, +} | undefined; + +export type InstalledAppsMap = Record; + /** * Get all available app IDs, filtering out alpha apps in production */ @@ -16,6 +23,33 @@ export function getAllAvailableAppIds(): AppId[] { return apps; } +/** + * Determines whether an app is enabled. + * - Regular apps are enabled via their own config entry. + * - Sub-apps are enabled when their parent app is enabled. + */ +export function isAppEnabled(installedApps: InstalledAppsMap, appId: AppId): boolean { + const appFrontend = ALL_APPS_FRONTEND[appId]; + if (isSubApp(appFrontend)) { + return installedApps[appFrontend.parentAppId]?.enabled ?? false; + } + return installedApps[appId]?.enabled ?? false; +} + +/** + * Get all enabled app IDs using centralized enabled/sub-app logic. + */ +export function getEnabledAppIds(installedApps: InstalledAppsMap): AppId[] { + return getAllAvailableAppIds().filter((appId) => isAppEnabled(installedApps, appId)); +} + +/** + * Get enabled apps that expose sidebar/cmdk navigation items. + */ +export function getEnabledNavigableAppIds(installedApps: InstalledAppsMap): AppId[] { + return getEnabledAppIds(installedApps).filter((appId) => hasNavigationItems(ALL_APPS_FRONTEND[appId])); +} + /** * Get uninstalled app IDs (available but not installed) */ diff --git a/packages/stack-shared/src/apps/apps-config.ts b/packages/stack-shared/src/apps/apps-config.ts index 429cb91ed..92a5cb972 100644 --- a/packages/stack-shared/src/apps/apps-config.ts +++ b/packages/stack-shared/src/apps/apps-config.ts @@ -54,6 +54,12 @@ export const ALL_APPS = { tags: ["auth", "security"], stage: "stable", }, + "fraud-protection": { + displayName: "Fraud Protection", + subtitle: "Protect your project from fraud and abuse", + tags: ["auth", "security"], + stage: "stable", + }, "onboarding": { displayName: "Onboarding", subtitle: "Configure user onboarding requirements",