Fraud Protection sub-app

This commit is contained in:
Konstantin Wohlwend 2026-04-05 21:34:59 -07:00
parent ce49eae155
commit 9b1284dc9e
12 changed files with 346 additions and 97 deletions

View File

@ -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) => {

View File

@ -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) {

View File

@ -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 () => {

View File

@ -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]
);

View File

@ -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 (
<NavItem
item={navItemData}
@ -426,9 +434,7 @@ function SidebarContent({
// Memoize enabledApps to prevent recalculation on every render
const enabledApps = useMemo(() =>
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]
);

View File

@ -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({
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-[120px]">
<DropdownMenuItem onClick={handleToggleEnabled} className="cursor-pointer">
{isEnabled ? 'Disable' : 'Enable'}
</DropdownMenuItem>
{parentDestinationPath == null ? (
<DropdownMenuItem onClick={handleToggleEnabled} className="cursor-pointer">
{isEnabled ? 'Disable' : 'Enable'}
</DropdownMenuItem>
) : (
<DropdownMenuItem onClick={() => router.push(parentDestinationPath)} className="cursor-pointer">
Go to {parentApp?.displayName ?? "parent app"}
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
@ -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 (
<Link
href={isEnabled ? appPath : appDetailsPath}
href={parentDestinationPath ?? (isEnabled ? appPath : appDetailsPath)}
className={cn(
"flex items-center gap-3 p-3 rounded-lg transition-all",
"hover:bg-gray-50 dark:hover:bg-gray-800/50",
@ -258,6 +287,19 @@ export function AppListItem({
<div className="flex items-center gap-2">
{isEnabled ? (
<CheckIcon className="w-4 h-4 text-green-500" />
) : parentDestinationPath != null ? (
<Button
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
router.push(parentDestinationPath);
}}
variant="plain"
size="plain"
className="px-3 py-1 text-xs font-medium bg-muted text-foreground rounded-md hover:bg-muted/80 transition-colors"
>
Go to {parentApp?.displayName ?? "parent"}
</Button>
) : (
<Button
onClick={handleEnable}

View File

@ -2,8 +2,8 @@
import { AppIcon } from "@/components/app-square";
import { Badge, Button, Dialog, DialogContent, DialogTitle, ScrollArea, cn } from "@/components/ui";
import { ALL_APPS_FRONTEND, type AppId } from "@/lib/apps-frontend";
import { ArrowSquareOutIcon, CaretLeftIcon, CaretRightIcon, CheckIcon, LightningIcon, ShieldCheckIcon, XIcon } from "@phosphor-icons/react";
import { ALL_APPS_FRONTEND, isSubApp, type AppId } from "@/lib/apps-frontend";
import { ArrowRightIcon, CaretLeftIcon, CaretRightIcon, CheckIcon, LightningIcon, ShieldCheckIcon, XIcon } from "@phosphor-icons/react";
import { ALL_APPS, ALL_APP_TAGS } from "@stackframe/stack-shared/dist/apps/apps-config";
import Image from "next/image";
import { FunctionComponent, useCallback, useEffect, useRef, useState } from "react";
@ -25,6 +25,8 @@ export function AppStoreEntry({
}) {
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 screenshotContainerRef = useRef<HTMLDivElement>(null);
const [previewIndex, setPreviewIndex] = useState<number | null>(null);
@ -142,36 +144,52 @@ export function AppStoreEntry({
</div>
{/* CTA Button */}
<div className="flex items-center gap-4">
{isEnabled ? (
<div className={cn("flex gap-4", parentApp == null ? "items-center" : "flex-col items-start")}>
{parentApp == null ? (
isEnabled ? (
<>
<Button
onClick={onOpen}
size="lg"
className="px-8 bg-blue-600 hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600 text-white font-medium shadow-lg shadow-blue-500/20"
>
<ArrowRightIcon className="w-4 h-4 mr-2" />
Open App
</Button>
{onDisable && (
<Button
onClick={onDisable}
variant="ghost"
size="lg"
className="text-red-600 hover:bg-red-50 hover:text-red-700 dark:text-red-400 dark:hover:bg-red-950 dark:hover:text-red-300"
>
Disable
</Button>
)}
</>
) : (
<Button
onClick={onEnable}
size="lg"
className="px-8 bg-blue-600 hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600 text-white font-medium shadow-lg shadow-blue-500/20"
>
Enable App
</Button>
)
) : (
<>
<p className="text-xs text-muted-foreground">
This app is part of the {parentApp.displayName} app.
</p>
<Button
onClick={onOpen}
size="lg"
className="px-8 bg-blue-600 hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600 text-white font-medium shadow-lg shadow-blue-500/20"
>
<ArrowSquareOutIcon className="w-4 h-4 mr-2" />
Open App
<ArrowRightIcon className="w-4 h-4 mr-2" />
Go to {parentApp.displayName}
</Button>
{onDisable && (
<Button
onClick={onDisable}
variant="ghost"
size="lg"
className="text-red-600 hover:bg-red-50 hover:text-red-700 dark:text-red-400 dark:hover:bg-red-950 dark:hover:text-red-300"
>
Disable
</Button>
)}
</>
) : (
<Button
onClick={onEnable}
size="lg"
className="px-8 bg-blue-600 hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600 text-white font-medium shadow-lg shadow-blue-500/20"
>
Enable App
</Button>
)}
</div>
</div>

View File

@ -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<void>,
onEnable?: () => Promise<void>,
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 */}
<div className="flex items-center gap-3">
<Button
onClick={() => runAsynchronouslyWithAlert(onEnable())}
size="sm"
className="flex-1 bg-blue-600 hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600 text-white font-medium"
>
Enable App
</Button>
{parentApp == null ? (
<Button
onClick={() => {
if (onEnable == null) return;
runAsynchronouslyWithAlert(onEnable());
}}
size="sm"
className="flex-1 bg-blue-600 hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600 text-white font-medium"
>
Enable App
</Button>
) : (
<Button
asChild
size="sm"
className="flex-1 bg-blue-600 hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600 text-white font-medium"
>
<Link href={goToParentHref ?? "#"} onClick={onClose}>
Go to {parentApp.displayName}
</Link>
</Button>
)}
<div className="flex items-center gap-1 text-[10px] text-muted-foreground">
<InfoIcon className="w-3 h-3" />
<span>Free</span>
</div>
</div>
{parentApp != null && (
<p className="text-[11px] text-muted-foreground">
This app is part of the {parentApp.displayName} app.
</p>
)}
{/* 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<void>): React.ComponentType<CmdKPreviewProps> {
return function AvailableAppPreviewWrapper() {
return <AvailableAppPreview appId={appId} projectId={projectId} onEnable={onEnable} />;
function createAvailableAppPreview(
appId: AppId,
onEnable?: () => Promise<void>,
goToParentHref?: string
): React.ComponentType<CmdKPreviewProps> {
return function AvailableAppPreviewWrapper({ onClose }: CmdKPreviewProps) {
return <AvailableAppPreview appId={appId} onEnable={onEnable} goToParentHref={goToParentHref} onClose={onClose} />;
};
}
// Cache for available app preview components
const availableAppPreviewCache = new Map<string, React.ComponentType<CmdKPreviewProps>>();
function getOrCreateAvailableAppPreview(appId: AppId, projectId: string, onEnable: () => Promise<void>): React.ComponentType<CmdKPreviewProps> {
const cacheKey = `${appId}:${projectId}`;
function getOrCreateAvailableAppPreview(
appId: AppId,
projectId: string,
onEnable?: () => Promise<void>,
goToParentHref?: string
): React.ComponentType<CmdKPreviewProps> {
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<CmdKPreviewProps> {
function createAppPreview(appId: AppId, projectId: string, appFrontend: NavigableAppFrontend): React.ComponentType<CmdKPreviewProps> {
// 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: <IconComponent className="h-3.5 w-3.5 stroke-emerald-600 dark:stroke-emerald-400" />,
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: <IconComponent className="h-3.5 w-3.5 stroke-emerald-600 dark:stroke-emerald-400" />,
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({
</div>
),
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,
});
}

View File

@ -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);

View File

@ -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<React.SVGProps<SVGSVGElement>>,
logo?: React.FunctionComponent<{}>,
href: string,
matchPath?: (relativePart: string) => boolean,
getBreadcrumbItems?: (stackAdminApp: StackAdminApp<false>, relativePart: string) => Promise<BreadcrumbDefinition | null | undefined>,
navigationItems: AppNavigationItem[],
screenshots: (string | StaticImageData)[],
storeDescription: JSX.Element,
};
} & (
| {
navigationItems: AppNavigationItem[],
matchPath?: (relativePart: string) => boolean,
getBreadcrumbItems?: (stackAdminApp: StackAdminApp<false>, relativePart: string) => Promise<BreadcrumbDefinition | null | undefined>,
}
| {
parentAppId: AppId,
}
)
export type NavigableAppFrontend = Extract<AppFrontend, { navigationItems: AppNavigationItem[] }>;
export type SubAppFrontend = Extract<AppFrontend, { parentAppId: AppId }>;
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: <>
<p>Fraud Protection helps you protect your project from fraud and abuse.</p>
<p>Configure sign-up rules and use our built-in fraud protection features to detect bots, free trial abuse, and other fraudulent activity.</p>
</>,
},
onboarding: {
icon: ClipboardTextIcon,
href: "onboarding",

View File

@ -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<string, InstalledAppConfig>;
/**
* 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)
*/

View File

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