mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-04 21:04:37 +08:00
Fraud Protection sub-app
This commit is contained in:
parent
ce49eae155
commit
9b1284dc9e
@ -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) => {
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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]
|
||||
);
|
||||
|
||||
|
||||
@ -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]
|
||||
);
|
||||
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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)
|
||||
*/
|
||||
|
||||
@ -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",
|
||||
|
||||
Loading…
Reference in New Issue
Block a user