settings/apps page to view and delete published apps
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -41,6 +41,7 @@ export function App() {
|
|||||||
<Route path="/settings/resources" element={user?.role !== 'Member' ? <Dashboard.ResourceSettings /> : <Navigate to="/" replace />} />
|
<Route path="/settings/resources" element={user?.role !== 'Member' ? <Dashboard.ResourceSettings /> : <Navigate to="/" replace />} />
|
||||||
<Route path="/settings/users" element={user?.role === 'Super Admin' ? <Dashboard.UserSettings /> : <Navigate to="/" replace />} />
|
<Route path="/settings/users" element={user?.role === 'Super Admin' ? <Dashboard.UserSettings /> : <Navigate to="/" replace />} />
|
||||||
<Route path="/settings/integrations" element={<Dashboard.IntegrationsSettings />} />
|
<Route path="/settings/integrations" element={<Dashboard.IntegrationsSettings />} />
|
||||||
|
<Route path="/settings/apps" element={<Dashboard.AppsSettings />} />
|
||||||
<Route path="/automation" element={<Dashboard.Automation />} />
|
<Route path="/automation" element={<Dashboard.Automation />} />
|
||||||
<Route path="/chat" element={<Dashboard.SessionListPage />} />
|
<Route path="/chat" element={<Dashboard.SessionListPage />} />
|
||||||
<Route path="/chat/new" element={<Dashboard.SessionListPage isNew />} />
|
<Route path="/chat/new" element={<Dashboard.SessionListPage isNew />} />
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Link } from 'react-router';
|
import { Link } from 'react-router';
|
||||||
import * as Dropdown from '@/components/ui/dropdown-menu';
|
import * as Dropdown from '@/components/ui/dropdown-menu';
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||||
import { User, Users, LogOut, Settings, Package, Puzzle, Sun, Moon } from 'lucide-react';
|
import { User, Users, LogOut, Settings, Package, Puzzle, Rocket, Sun, Moon } from 'lucide-react';
|
||||||
import { useAuth } from 'hooks/useAuth';
|
import { useAuth } from 'hooks/useAuth';
|
||||||
import { useTranslation } from '@/lib/i18n';
|
import { useTranslation } from '@/lib/i18n';
|
||||||
import { useColorMode } from '@/components/ui/ThemeProvider';
|
import { useColorMode } from '@/components/ui/ThemeProvider';
|
||||||
@@ -65,6 +65,12 @@ export function UserMenu() {
|
|||||||
Integrations
|
Integrations
|
||||||
</Link>
|
</Link>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem asChild className="cursor-pointer">
|
||||||
|
<Link to="/settings/apps">
|
||||||
|
<Rocket className="mr-2 h-4 w-4" />
|
||||||
|
Apps
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
{user?.role === 'Super Admin' && (
|
{user?.role === 'Super Admin' && (
|
||||||
<DropdownMenuItem asChild className="cursor-pointer">
|
<DropdownMenuItem asChild className="cursor-pointer">
|
||||||
<Link to="/settings/users">
|
<Link to="/settings/users">
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Trash2, Loader2, ExternalLink } from 'lucide-react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { useClient } from 'hooks/useClient';
|
||||||
|
import { useUserApps, type AppManifest } from 'state/useUserApps';
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from '@/components/ui/alert-dialog';
|
||||||
|
|
||||||
|
export const AppsList = () => {
|
||||||
|
const client = useClient();
|
||||||
|
const { apps, isLoading, refetch } = useUserApps();
|
||||||
|
const [deleting, setDeleting] = useState<AppManifest | null>(null);
|
||||||
|
const [deletingInProgress, setDeletingInProgress] = useState(false);
|
||||||
|
|
||||||
|
const confirmDelete = async () => {
|
||||||
|
if (!deleting) return;
|
||||||
|
setDeletingInProgress(true);
|
||||||
|
try {
|
||||||
|
await client.delete(`/apps/${deleting.slug}`);
|
||||||
|
toast.success(`Deleted ${deleting.name}`);
|
||||||
|
refetch();
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to delete app');
|
||||||
|
} finally {
|
||||||
|
setDeletingInProgress(false);
|
||||||
|
setDeleting(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<Loader2 className="h-5 w-5 animate-spin text-duck-dark/30 dark:text-foreground/30" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (apps.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="text-sm text-duck-dark/40 dark:text-foreground/40 py-8 text-center">
|
||||||
|
No published apps yet. Publish a project from the Projects page.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{apps.map((app) => (
|
||||||
|
<div
|
||||||
|
key={app.slug}
|
||||||
|
className="flex items-center gap-4 rounded-lg border border-duck-dark/10 dark:border-foreground/10 p-4"
|
||||||
|
>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm font-medium text-duck-dark dark:text-foreground">{app.name}</span>
|
||||||
|
<span className="text-[10px] font-mono text-duck-dark/30 dark:text-foreground/30">v{app.version}</span>
|
||||||
|
</div>
|
||||||
|
{app.description && (
|
||||||
|
<p className="text-xs text-duck-dark/50 dark:text-foreground/50 mt-0.5 truncate">{app.description}</p>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-3 mt-1.5 text-[11px] text-duck-dark/30 dark:text-foreground/30">
|
||||||
|
<span>Source: <span className="font-mono">{app.sourceProject}</span></span>
|
||||||
|
<span>Commit: <span className="font-mono">{app.commitHash}</span></span>
|
||||||
|
<span>Published: {new Date(app.publishedAt).toLocaleDateString()}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
href={`/api/app-serve/${app.slug}/`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="shrink-0 p-2 rounded-md text-duck-dark/30 dark:text-foreground/30 hover:text-duck-teal hover:bg-duck-teal/10 transition-colors cursor-pointer"
|
||||||
|
title="Open in new tab"
|
||||||
|
>
|
||||||
|
<ExternalLink className="h-4 w-4" />
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setDeleting(app)}
|
||||||
|
className="shrink-0 p-2 rounded-md text-duck-dark/30 dark:text-foreground/30 hover:text-red-500 hover:bg-red-500/10 transition-colors cursor-pointer"
|
||||||
|
title="Delete app"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AlertDialog open={deleting !== null} onOpenChange={(open) => { if (!open) setDeleting(null); }}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Delete published app</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
Are you sure you want to delete <strong>{deleting?.name}</strong>? This removes the published build. The source project is not affected.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel disabled={deletingInProgress}>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={confirmDelete} disabled={deletingInProgress} className="bg-red-600 hover:bg-red-700">
|
||||||
|
{deletingInProgress ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Delete'}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
import { Rocket, List } from 'lucide-react';
|
||||||
|
import type { LayoutNode, PanelComponents } from 'officerdev';
|
||||||
|
import { WorkspaceLayout } from 'officerdev';
|
||||||
|
|
||||||
|
import { createSettingsPanelComponents, type SettingsSection } from '../SettingsPanel';
|
||||||
|
import { AppsList } from './AppsList';
|
||||||
|
|
||||||
|
const GLOBAL_KEY = 'APPS_SETTINGS_SELECTED';
|
||||||
|
|
||||||
|
const sections: SettingsSection[] = [
|
||||||
|
{ key: 'published-apps', icon: List, title: 'Published Apps', description: 'View and manage your published apps', content: <AppsList /> },
|
||||||
|
];
|
||||||
|
|
||||||
|
const { Sidebar, Content } = createSettingsPanelComponents({
|
||||||
|
globalKey: GLOBAL_KEY,
|
||||||
|
sidebarIcon: Rocket,
|
||||||
|
sidebarLabel: 'Apps',
|
||||||
|
sections,
|
||||||
|
});
|
||||||
|
|
||||||
|
const layout: LayoutNode = {
|
||||||
|
type: 'group',
|
||||||
|
id: 'apps-settings-root',
|
||||||
|
direction: 'horizontal',
|
||||||
|
children: [
|
||||||
|
{ node: { type: 'panel', id: 'apps-settings-left', appType: null }, size: 20 },
|
||||||
|
{ node: { type: 'panel', id: 'apps-settings-right', appType: null }, size: 80 },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AppsSettings = () => {
|
||||||
|
const panelComponents: PanelComponents = useMemo(
|
||||||
|
() => ({
|
||||||
|
'apps-settings-left': Sidebar,
|
||||||
|
'apps-settings-right': Content,
|
||||||
|
}),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full w-full pt-2">
|
||||||
|
<WorkspaceLayout layout={layout} onLayoutChange={() => {}} components={panelComponents} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -3,3 +3,4 @@ export * from './SystemSettings';
|
|||||||
export * from './ResourceSettings';
|
export * from './ResourceSettings';
|
||||||
export * from './UserSettings';
|
export * from './UserSettings';
|
||||||
export * from './IntegrationsSettings';
|
export * from './IntegrationsSettings';
|
||||||
|
export * from './AppsSettings';
|
||||||
|
|||||||
Reference in New Issue
Block a user