Sfoglia il codice sorgente

feat: move app details to rsc

Nicolas Meienberger 1 anno fa
parent
commit
3bf7f65b3d
44 ha cambiato i file con 462 aggiunte e 766 eliminazioni
  1. 1 1
      src/app/(dashboard)/app-store/[id]/components/AppActions/AppActions.test.tsx
  2. 5 3
      src/app/(dashboard)/app-store/[id]/components/AppActions/AppActions.tsx
  3. 0 0
      src/app/(dashboard)/app-store/[id]/components/AppActions/index.ts
  4. 219 0
      src/app/(dashboard)/app-store/[id]/components/AppDetailsContainer/AppDetailsContainer.tsx
  5. 0 0
      src/app/(dashboard)/app-store/[id]/components/AppDetailsContainer/index.ts
  6. 2 2
      src/app/(dashboard)/app-store/[id]/components/AppDetailsTabs/AppDetailsTabs.tsx
  7. 1 0
      src/app/(dashboard)/app-store/[id]/components/AppDetailsTabs/index.ts
  8. 1 1
      src/app/(dashboard)/app-store/[id]/components/InstallForm/InstallForm.test.tsx
  9. 3 3
      src/app/(dashboard)/app-store/[id]/components/InstallForm/InstallForm.tsx
  10. 1 0
      src/app/(dashboard)/app-store/[id]/components/InstallForm/index.ts
  11. 1 1
      src/app/(dashboard)/app-store/[id]/components/InstallModal/InstallModal.test.tsx
  12. 1 2
      src/app/(dashboard)/app-store/[id]/components/InstallModal/InstallModal.tsx
  13. 0 0
      src/app/(dashboard)/app-store/[id]/components/InstallModal/index.ts
  14. 1 1
      src/app/(dashboard)/app-store/[id]/components/StopModal/StopModal.tsx
  15. 1 0
      src/app/(dashboard)/app-store/[id]/components/StopModal/index.ts
  16. 1 1
      src/app/(dashboard)/app-store/[id]/components/UninstallModal/UninstallModal.tsx
  17. 1 0
      src/app/(dashboard)/app-store/[id]/components/UninstallModal/index.ts
  18. 1 1
      src/app/(dashboard)/app-store/[id]/components/UpdateModal/UpdateModal.test.tsx
  19. 1 1
      src/app/(dashboard)/app-store/[id]/components/UpdateModal/UpdateModal.tsx
  20. 0 0
      src/app/(dashboard)/app-store/[id]/components/UpdateModal/index.ts
  21. 1 2
      src/app/(dashboard)/app-store/[id]/components/UpdateSettingsModal/UpdateSettingsModal.tsx
  22. 0 0
      src/app/(dashboard)/app-store/[id]/components/UpdateSettingsModal/index.ts
  23. 23 0
      src/app/(dashboard)/app-store/[id]/page.tsx
  24. 0 0
      src/app/(dashboard)/app-store/[id]/utils/validators/index.ts
  25. 0 0
      src/app/(dashboard)/app-store/[id]/utils/validators/validators.test.tsx
  26. 0 0
      src/app/(dashboard)/app-store/[id]/utils/validators/validators.ts
  27. 2 1
      src/app/(dashboard)/components/PageTitle/PageTitle.tsx
  28. 36 0
      src/app/actions/app-actions/install-app-action.ts
  29. 29 0
      src/app/actions/app-actions/start-app-action.ts
  30. 29 0
      src/app/actions/app-actions/stop-app-action.ts
  31. 29 0
      src/app/actions/app-actions/uninstall-app-action.ts
  32. 29 0
      src/app/actions/app-actions/update-app-action.ts
  33. 36 0
      src/app/actions/app-actions/update-app-config-action.ts
  34. 0 1
      src/client/modules/Apps/components/InstallForm/index.ts
  35. 0 410
      src/client/modules/Apps/containers/AppDetailsContainer/AppDetailsContainer.test.tsx
  36. 0 208
      src/client/modules/Apps/containers/AppDetailsContainer/AppDetailsContainer.tsx
  37. 0 58
      src/client/modules/Apps/pages/AppDetailsPage/AppDetailsPage.test.tsx
  38. 0 42
      src/client/modules/Apps/pages/AppDetailsPage/AppDetailsPage.tsx
  39. 0 1
      src/client/modules/Apps/pages/AppDetailsPage/index.ts
  40. 5 5
      src/client/modules/Settings/components/OtpForm/OptForm.test.tsx
  41. 0 0
      src/lib/helpers/castAppConfig.ts
  42. 0 19
      src/pages/app-store/[id].tsx
  43. 1 1
      src/server/services/apps/apps.service.test.ts
  44. 1 1
      src/server/services/apps/apps.service.ts

+ 1 - 1
src/client/modules/Apps/components/AppActions/AppActions.test.tsx → src/app/(dashboard)/app-store/[id]/components/AppActions/AppActions.test.tsx

@@ -1,7 +1,7 @@
 import React from 'react';
 import { AppInfo } from '@runtipi/shared';
 import { AppActions } from './AppActions';
-import { cleanup, fireEvent, render, screen, waitFor, userEvent } from '../../../../../../tests/test-utils';
+import { cleanup, fireEvent, render, screen, waitFor, userEvent } from '../../../../../../../tests/test-utils';
 
 afterEach(cleanup);
 

+ 5 - 3
src/client/modules/Apps/components/AppActions/AppActions.tsx → src/app/(dashboard)/app-store/[id]/components/AppActions/AppActions.tsx

@@ -5,8 +5,8 @@ import type { AppStatus } from '@/server/db/schema';
 
 import { useTranslations } from 'next-intl';
 import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, DropdownMenuTrigger } from '@/components/ui/DropdownMenu';
-import { Button } from '../../../../components/ui/Button';
-import { AppWithInfo } from '../../../../core/types';
+import { AppWithInfo } from '@/client/core/types';
+import { Button } from '@/components/ui/Button';
 
 interface IProps {
   app: AppWithInfo;
@@ -52,6 +52,8 @@ export const AppActions: React.FC<IProps> = ({ app, status, localDomain, onInsta
   const t = useTranslations('apps.app-details');
   const hasSettings = Object.keys(info.form_fields).length > 0 || info.exposable;
 
+  const hostname = typeof window !== 'undefined' ? window.location.hostname : '';
+
   const buttons: JSX.Element[] = [];
 
   const StartButton = <ActionButton key="start" IconComponent={IconPlayerPlay} onClick={onStart} title={t('actions.start')} color="success" />;
@@ -87,7 +89,7 @@ export const AppActions: React.FC<IProps> = ({ app, status, localDomain, onInsta
           {!app.info.force_expose && (
             <DropdownMenuItem onClick={() => onOpen('local')}>
               <IconLockOff className="text-muted me-2" size={16} />
-              {window.location.hostname}:{app.info.port}
+              {hostname}:{app.info.port}
             </DropdownMenuItem>
           )}
         </DropdownMenuGroup>

+ 0 - 0
src/client/modules/Apps/components/AppActions/index.ts → src/app/(dashboard)/app-store/[id]/components/AppActions/index.ts


+ 219 - 0
src/app/(dashboard)/app-store/[id]/components/AppDetailsContainer/AppDetailsContainer.tsx

@@ -0,0 +1,219 @@
+'use client';
+
+import React from 'react';
+import { toast } from 'react-hot-toast';
+import { useTranslations } from 'next-intl';
+import { AppRouterOutput } from '@/server/routers/app/app.router';
+import { useDisclosure } from '@/client/hooks/useDisclosure';
+import { useAction } from 'next-safe-action/hook';
+import { installAppAction } from '@/actions/app-actions/install-app-action';
+import { uninstallAppAction } from '@/actions/app-actions/uninstall-app-action';
+import { stopAppAction } from '@/actions/app-actions/stop-app-action';
+import { startAppAction } from '@/actions/app-actions/start-app-action';
+import { updateAppAction } from '@/actions/app-actions/update-app-action';
+import { updateAppConfigAction } from '@/actions/app-actions/update-app-config-action';
+import { AppLogo } from '@/components/AppLogo';
+import { AppStatus } from '@/components/AppStatus';
+import { AppStatus as AppStatusEnum } from '@/server/db/schema';
+import { castAppConfig } from '@/lib/helpers/castAppConfig';
+import { InstallModal } from '../InstallModal';
+import { StopModal } from '../StopModal';
+import { UninstallModal } from '../UninstallModal';
+import { UpdateModal } from '../UpdateModal';
+import { UpdateSettingsModal } from '../UpdateSettingsModal/UpdateSettingsModal';
+import { AppActions } from '../AppActions';
+import { AppDetailsTabs } from '../AppDetailsTabs';
+import { FormValues } from '../InstallForm';
+
+interface IProps {
+  app: AppRouterOutput['getApp'];
+  localDomain?: string;
+}
+type OpenType = 'local' | 'domain' | 'local_domain';
+
+export const AppDetailsContainer: React.FC<IProps> = ({ app, localDomain }) => {
+  const [customStatus, setCustomStatus] = React.useState<AppStatusEnum>(app.status);
+
+  const t = useTranslations();
+  const installDisclosure = useDisclosure();
+  const uninstallDisclosure = useDisclosure();
+  const stopDisclosure = useDisclosure();
+  const updateDisclosure = useDisclosure();
+  const updateSettingsDisclosure = useDisclosure();
+
+  const installMutation = useAction(installAppAction, {
+    onSuccess: (data) => {
+      if (!data.success) {
+        setCustomStatus(app.status);
+        toast.error(data.failure.reason);
+      } else {
+        setCustomStatus('running');
+        toast.success(t('apps.app-details.install-success'));
+      }
+    },
+  });
+
+  const uninstallMutation = useAction(uninstallAppAction, {
+    onSuccess: (data) => {
+      if (!data.success) {
+        setCustomStatus(app.status);
+        toast.error(data.failure.reason);
+      } else {
+        setCustomStatus('missing');
+        toast.success(t('apps.app-details.uninstall-success'));
+      }
+    },
+  });
+
+  const stopMutation = useAction(stopAppAction, {
+    onSuccess: (data) => {
+      if (!data.success) {
+        setCustomStatus(app.status);
+        toast.error(data.failure.reason);
+      } else {
+        setCustomStatus('stopped');
+        toast.success(t('apps.app-details.stop-success'));
+      }
+    },
+  });
+
+  const startMutation = useAction(startAppAction, {
+    onSuccess: (data) => {
+      if (!data.success) {
+        setCustomStatus(app.status);
+        toast.error(data.failure.reason);
+      } else {
+        setCustomStatus('running');
+        toast.success(t('apps.app-details.start-success'));
+      }
+    },
+  });
+
+  const updateMutation = useAction(updateAppAction, {
+    onSuccess: (data) => {
+      if (!data.success) {
+        setCustomStatus(app.status);
+        toast.error(data.failure.reason);
+      } else {
+        setCustomStatus('stopped');
+        toast.success(t('apps.app-details.update-success'));
+      }
+    },
+  });
+
+  const updateConfigMutation = useAction(updateAppConfigAction, {
+    onSuccess: (data) => {
+      if (!data.success) {
+        toast.error(data.failure.reason);
+      } else {
+        toast.success(t('apps.app-details.update-config-success'));
+      }
+    },
+  });
+
+  const updateAvailable = Number(app.version || 0) < Number(app?.latestVersion || 0);
+
+  const handleInstallSubmit = async (values: FormValues) => {
+    setCustomStatus('installing');
+    installDisclosure.close();
+    const { exposed, domain } = values;
+    installMutation.execute({ id: app.id, form: values, exposed, domain });
+  };
+
+  const handleUnistallSubmit = () => {
+    setCustomStatus('uninstalling');
+    uninstallDisclosure.close();
+    uninstallMutation.execute({ id: app.id });
+  };
+
+  const handleStopSubmit = () => {
+    setCustomStatus('stopping');
+    stopDisclosure.close();
+    stopMutation.execute({ id: app.id });
+  };
+
+  const handleStartSubmit = async () => {
+    setCustomStatus('starting');
+    startMutation.execute({ id: app.id });
+  };
+
+  const handleUpdateSettingsSubmit = async (values: FormValues) => {
+    updateSettingsDisclosure.close();
+    const { exposed, domain } = values;
+    updateConfigMutation.execute({ id: app.id, form: values, exposed, domain });
+  };
+
+  const handleUpdateSubmit = async () => {
+    setCustomStatus('updating');
+    updateDisclosure.close();
+    updateMutation.execute({ id: app.id });
+  };
+
+  const handleOpen = (type: OpenType) => {
+    let url = '';
+    const { https } = app.info;
+    const protocol = https ? 'https' : 'http';
+
+    if (typeof window !== 'undefined') {
+      // Current domain
+      const domain = window.location.hostname;
+      url = `${protocol}://${domain}:${app.info.port}${app.info.url_suffix || ''}`;
+    }
+
+    if (type === 'domain' && app.domain) {
+      url = `https://${app.domain}${app.info.url_suffix || ''}`;
+    }
+
+    if (type === 'local_domain') {
+      url = `https://${app.id}.${localDomain}`;
+    }
+
+    window.open(url, '_blank', 'noreferrer');
+  };
+
+  const newVersion = [app?.latestDockerVersion ? `${app?.latestDockerVersion}` : '', `(${String(app?.latestVersion)})`].join(' ');
+
+  return (
+    <div className="card" data-testid="app-details">
+      <InstallModal onSubmit={handleInstallSubmit} isOpen={installDisclosure.isOpen} onClose={installDisclosure.close} info={app.info} />
+      <StopModal onConfirm={handleStopSubmit} isOpen={stopDisclosure.isOpen} onClose={stopDisclosure.close} info={app.info} />
+      <UninstallModal onConfirm={handleUnistallSubmit} isOpen={uninstallDisclosure.isOpen} onClose={uninstallDisclosure.close} info={app.info} />
+      <UpdateModal onConfirm={handleUpdateSubmit} isOpen={updateDisclosure.isOpen} onClose={updateDisclosure.close} info={app.info} newVersion={newVersion} />
+      <UpdateSettingsModal
+        onSubmit={handleUpdateSettingsSubmit}
+        isOpen={updateSettingsDisclosure.isOpen}
+        onClose={updateSettingsDisclosure.close}
+        info={app.info}
+        config={castAppConfig(app?.config)}
+        exposed={app?.exposed}
+        domain={app?.domain || ''}
+      />
+      <div className="card-header d-flex flex-column flex-md-row">
+        <AppLogo id={app.id} size={130} alt={app.info.name} />
+        <div className="w-100 d-flex flex-column ms-md-3 align-items-center align-items-md-start">
+          <div>
+            <span className="mt-1 me-1">{t('apps.app-details.version')}: </span>
+            <span className="badge bg-gray mt-2">{app.info.version}</span>
+          </div>
+          <span className="mt-1 text-muted text-center mb-2">{app.info.short_desc}</span>
+          <div className="mb-1">{customStatus !== 'missing' && <AppStatus status={customStatus} />}</div>
+          <AppActions
+            localDomain={localDomain}
+            updateAvailable={updateAvailable}
+            onUpdate={updateDisclosure.open}
+            onUpdateSettings={updateSettingsDisclosure.open}
+            onStop={stopDisclosure.open}
+            onCancel={stopDisclosure.open}
+            onUninstall={uninstallDisclosure.open}
+            onInstall={installDisclosure.open}
+            onOpen={handleOpen}
+            onStart={handleStartSubmit}
+            app={app}
+            status={customStatus}
+          />
+        </div>
+      </div>
+      <AppDetailsTabs info={app.info} />
+    </div>
+  );
+};

+ 0 - 0
src/app/(dashboard)/app-store/[id]/components/AppDetailsContainer/index.ts


+ 2 - 2
src/client/modules/Apps/components/AppDetailsTabs.tsx → src/app/(dashboard)/app-store/[id]/components/AppDetailsTabs/AppDetailsTabs.tsx

@@ -3,8 +3,8 @@ import React from 'react';
 import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
 import { useTranslations } from 'next-intl';
 import { AppInfo } from '@runtipi/shared';
-import { DataGrid, DataGridItem } from '../../../components/ui/DataGrid';
-import Markdown from '../../../components/Markdown/Markdown';
+import Markdown from '@/components/Markdown/Markdown';
+import { DataGrid, DataGridItem } from '@/components/ui/DataGrid';
 
 interface IProps {
   info: AppInfo;

+ 1 - 0
src/app/(dashboard)/app-store/[id]/components/AppDetailsTabs/index.ts

@@ -0,0 +1 @@
+export { AppDetailsTabs } from './AppDetailsTabs';

+ 1 - 1
src/client/modules/Apps/components/InstallForm/InstallForm.test.tsx → src/app/(dashboard)/app-store/[id]/components/InstallForm/InstallForm.test.tsx

@@ -2,7 +2,7 @@ import React from 'react';
 import { faker } from '@faker-js/faker';
 import { fromPartial } from '@total-typescript/shoehorn';
 import { FormField } from '@runtipi/shared';
-import { fireEvent, render, screen, waitFor } from '../../../../../../tests/test-utils';
+import { fireEvent, render, screen, waitFor } from '../../../../../../../tests/test-utils';
 import { InstallForm } from './InstallForm';
 
 describe('Test: InstallForm', () => {

+ 3 - 3
src/client/modules/Apps/components/InstallForm/InstallForm.tsx → src/app/(dashboard)/app-store/[id]/components/InstallForm/InstallForm.tsx

@@ -6,9 +6,9 @@ import { Tooltip } from 'react-tooltip';
 import clsx from 'clsx';
 import { useTranslations } from 'next-intl';
 import { type FormField, type AppInfo } from '@runtipi/shared';
-import { Button } from '../../../../components/ui/Button';
-import { Switch } from '../../../../components/ui/Switch';
-import { Input } from '../../../../components/ui/Input';
+import { Switch } from '@/components/ui/Switch';
+import { Input } from '@/components/ui/Input';
+import { Button } from '@/components/ui/Button';
 import { validateAppConfig } from '../../utils/validators';
 
 interface IProps {

+ 1 - 0
src/app/(dashboard)/app-store/[id]/components/InstallForm/index.ts

@@ -0,0 +1 @@
+export { InstallForm, type FormValues } from './InstallForm';

+ 1 - 1
src/client/modules/Apps/components/InstallModal/InstallModal.test.tsx → src/app/(dashboard)/app-store/[id]/components/InstallModal/InstallModal.test.tsx

@@ -1,7 +1,7 @@
 import React from 'react';
 import { AppInfo } from '@runtipi/shared';
 import { InstallModal } from './InstallModal';
-import { fireEvent, render, screen, waitFor } from '../../../../../../tests/test-utils';
+import { fireEvent, render, screen, waitFor } from '../../../../../../../tests/test-utils';
 
 describe('InstallModal', () => {
   const app = {

+ 1 - 2
src/client/modules/Apps/components/InstallModal/InstallModal.tsx → src/app/(dashboard)/app-store/[id]/components/InstallModal/InstallModal.tsx

@@ -2,8 +2,7 @@ import React from 'react';
 import { Dialog, DialogContent, DialogDescription, DialogHeader } from '@/components/ui/Dialog';
 import { useTranslations } from 'next-intl';
 import { AppInfo } from '@runtipi/shared';
-import { InstallForm } from '../InstallForm';
-import { FormValues } from '../InstallForm/InstallForm';
+import { InstallForm, FormValues } from '../InstallForm';
 
 interface IProps {
   info: AppInfo;

+ 0 - 0
src/client/modules/Apps/components/InstallModal/index.ts → src/app/(dashboard)/app-store/[id]/components/InstallModal/index.ts


+ 1 - 1
src/client/modules/Apps/components/StopModal.tsx → src/app/(dashboard)/app-store/[id]/components/StopModal/StopModal.tsx

@@ -2,7 +2,7 @@ import React from 'react';
 import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader } from '@/components/ui/Dialog';
 import { useTranslations } from 'next-intl';
 import { AppInfo } from '@runtipi/shared';
-import { Button } from '../../../components/ui/Button';
+import { Button } from '@/components/ui/Button';
 
 interface IProps {
   info: AppInfo;

+ 1 - 0
src/app/(dashboard)/app-store/[id]/components/StopModal/index.ts

@@ -0,0 +1 @@
+export { StopModal } from './StopModal';

+ 1 - 1
src/client/modules/Apps/components/UninstallModal.tsx → src/app/(dashboard)/app-store/[id]/components/UninstallModal/UninstallModal.tsx

@@ -3,7 +3,7 @@ import React from 'react';
 import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader } from '@/components/ui/Dialog';
 import { useTranslations } from 'next-intl';
 import { AppInfo } from '@runtipi/shared';
-import { Button } from '../../../components/ui/Button';
+import { Button } from '@/components/ui/Button';
 
 interface IProps {
   info: AppInfo;

+ 1 - 0
src/app/(dashboard)/app-store/[id]/components/UninstallModal/index.ts

@@ -0,0 +1 @@
+export { UninstallModal } from './UninstallModal';

+ 1 - 1
src/client/modules/Apps/components/UpdateModal/UpdateModal.test.tsx → src/app/(dashboard)/app-store/[id]/components/UpdateModal/UpdateModal.test.tsx

@@ -1,5 +1,5 @@
 import React from 'react';
-import { fireEvent, render, screen } from '../../../../../../tests/test-utils';
+import { fireEvent, render, screen } from '../../../../../../../tests/test-utils';
 import { UpdateModal } from './UpdateModal';
 
 describe('UpdateModal', () => {

+ 1 - 1
src/client/modules/Apps/components/UpdateModal/UpdateModal.tsx → src/app/(dashboard)/app-store/[id]/components/UpdateModal/UpdateModal.tsx

@@ -2,7 +2,7 @@ import React from 'react';
 import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader } from '@/components/ui/Dialog';
 import { useTranslations } from 'next-intl';
 import { AppInfo } from '@runtipi/shared';
-import { Button } from '../../../../components/ui/Button';
+import { Button } from '@/components/ui/Button';
 
 interface IProps {
   newVersion: string;

+ 0 - 0
src/client/modules/Apps/components/UpdateModal/index.ts → src/app/(dashboard)/app-store/[id]/components/UpdateModal/index.ts


+ 1 - 2
src/client/modules/Apps/components/UpdateSettingsModal.tsx → src/app/(dashboard)/app-store/[id]/components/UpdateSettingsModal/UpdateSettingsModal.tsx

@@ -2,8 +2,7 @@ import React from 'react';
 import { Dialog, DialogContent, DialogDescription, DialogHeader } from '@/components/ui/Dialog';
 import { useTranslations } from 'next-intl';
 import { AppInfo } from '@runtipi/shared';
-import { InstallForm } from './InstallForm';
-import { FormValues } from './InstallForm/InstallForm';
+import { InstallForm, type FormValues } from '../InstallForm';
 
 interface IProps {
   info: AppInfo;

+ 0 - 0
src/app/(dashboard)/app-store/[id]/components/UpdateSettingsModal/index.ts


+ 23 - 0
src/app/(dashboard)/app-store/[id]/page.tsx

@@ -0,0 +1,23 @@
+import { AppServiceClass } from '@/server/services/apps/apps.service';
+import React from 'react';
+import { Metadata } from 'next';
+import { db } from '@/server/db';
+import { getTranslatorFromCookie } from '@/lib/get-translator';
+import { getSettings } from '@/server/core/TipiConfig';
+import { AppDetailsContainer } from './components/AppDetailsContainer/AppDetailsContainer';
+
+export async function generateMetadata(): Promise<Metadata> {
+  const translator = await getTranslatorFromCookie();
+
+  return {
+    title: `${translator('apps.app-store.title')} - Tipi`,
+  };
+}
+
+export default async function AppDetailsPage({ params }: { params: { id: string } }) {
+  const appsService = new AppServiceClass(db);
+  const app = await appsService.getApp(params.id);
+  const settings = getSettings();
+
+  return <AppDetailsContainer app={app} localDomain={settings.localDomain} />;
+}

+ 0 - 0
src/client/modules/Apps/utils/validators/index.ts → src/app/(dashboard)/app-store/[id]/utils/validators/index.ts


+ 0 - 0
src/client/modules/Apps/utils/validators/validators.test.tsx → src/app/(dashboard)/app-store/[id]/utils/validators/validators.test.tsx


+ 0 - 0
src/client/modules/Apps/utils/validators/validators.ts → src/app/(dashboard)/app-store/[id]/utils/validators/validators.ts


+ 2 - 1
src/app/(dashboard)/components/PageTitle/PageTitle.tsx

@@ -26,7 +26,8 @@ export const PageTitle = () => {
     );
   };
 
-  const title = t(`header.${pathArray[pathArray.length - 1]}` as MessageKey);
+  const appTitle = apps.find((app) => app.id === pathArray[1])?.name;
+  const title = appTitle ?? t(`header.${pathArray[pathArray.length - 1]}` as MessageKey);
 
   return (
     <>

+ 36 - 0
src/app/actions/app-actions/install-app-action.ts

@@ -0,0 +1,36 @@
+'use server';
+
+import { z } from 'zod';
+import { db } from '@/server/db';
+import { action } from '@/lib/safe-action';
+import { revalidatePath } from 'next/cache';
+import { AppServiceClass } from '@/server/services/apps/apps.service';
+import { handleActionError } from '../utils/handle-action-error';
+
+const formSchema = z.object({}).catchall(z.any());
+
+const input = z.object({
+  id: z.string(),
+  form: formSchema,
+  exposed: z.boolean().optional(),
+  domain: z.string().optional(),
+});
+
+/**
+ * Given an app id, installs the app.
+ */
+export const installAppAction = action(input, async ({ id, form, domain, exposed }) => {
+  try {
+    const appsService = new AppServiceClass(db);
+
+    await appsService.installApp(id, form, exposed, domain);
+
+    revalidatePath('/apps');
+    revalidatePath(`/app/${id}`);
+    revalidatePath(`/app-store/${id}`);
+
+    return { success: true };
+  } catch (e) {
+    return handleActionError(e);
+  }
+});

+ 29 - 0
src/app/actions/app-actions/start-app-action.ts

@@ -0,0 +1,29 @@
+'use server';
+
+import { z } from 'zod';
+import { db } from '@/server/db';
+import { action } from '@/lib/safe-action';
+import { revalidatePath } from 'next/cache';
+import { AppServiceClass } from '@/server/services/apps/apps.service';
+import { handleActionError } from '../utils/handle-action-error';
+
+const input = z.object({ id: z.string() });
+
+/**
+ * Given an app id, starts the app.
+ */
+export const startAppAction = action(input, async ({ id }) => {
+  try {
+    const appsService = new AppServiceClass(db);
+
+    await appsService.startApp(id);
+
+    revalidatePath('/apps');
+    revalidatePath(`/app/${id}`);
+    revalidatePath(`/app-store/${id}`);
+
+    return { success: true };
+  } catch (e) {
+    return handleActionError(e);
+  }
+});

+ 29 - 0
src/app/actions/app-actions/stop-app-action.ts

@@ -0,0 +1,29 @@
+'use server';
+
+import { z } from 'zod';
+import { db } from '@/server/db';
+import { action } from '@/lib/safe-action';
+import { revalidatePath } from 'next/cache';
+import { AppServiceClass } from '@/server/services/apps/apps.service';
+import { handleActionError } from '../utils/handle-action-error';
+
+const input = z.object({ id: z.string() });
+
+/**
+ * Given an app id, stops the app.
+ */
+export const stopAppAction = action(input, async ({ id }) => {
+  try {
+    const appsService = new AppServiceClass(db);
+
+    await appsService.stopApp(id);
+
+    revalidatePath('/apps');
+    revalidatePath(`/app/${id}`);
+    revalidatePath(`/app-store/${id}`);
+
+    return { success: true };
+  } catch (e) {
+    return handleActionError(e);
+  }
+});

+ 29 - 0
src/app/actions/app-actions/uninstall-app-action.ts

@@ -0,0 +1,29 @@
+'use server';
+
+import { z } from 'zod';
+import { db } from '@/server/db';
+import { action } from '@/lib/safe-action';
+import { revalidatePath } from 'next/cache';
+import { AppServiceClass } from '@/server/services/apps/apps.service';
+import { handleActionError } from '../utils/handle-action-error';
+
+const input = z.object({ id: z.string() });
+
+/**
+ * Given an app id, uninstalls the app.
+ */
+export const uninstallAppAction = action(input, async ({ id }) => {
+  try {
+    const appsService = new AppServiceClass(db);
+
+    await appsService.uninstallApp(id);
+
+    revalidatePath('/apps');
+    revalidatePath(`/app/${id}`);
+    revalidatePath(`/app-store/${id}`);
+
+    return { success: true };
+  } catch (e) {
+    return handleActionError(e);
+  }
+});

+ 29 - 0
src/app/actions/app-actions/update-app-action.ts

@@ -0,0 +1,29 @@
+'use server';
+
+import { z } from 'zod';
+import { db } from '@/server/db';
+import { action } from '@/lib/safe-action';
+import { revalidatePath } from 'next/cache';
+import { AppServiceClass } from '@/server/services/apps/apps.service';
+import { handleActionError } from '../utils/handle-action-error';
+
+const input = z.object({ id: z.string() });
+
+/**
+ * Given an app id, updates the app to the latest version
+ */
+export const updateAppAction = action(input, async ({ id }) => {
+  try {
+    const appsService = new AppServiceClass(db);
+
+    await appsService.updateApp(id);
+
+    revalidatePath('/apps');
+    revalidatePath(`/app/${id}`);
+    revalidatePath(`/app-store/${id}`);
+
+    return { success: true };
+  } catch (e) {
+    return handleActionError(e);
+  }
+});

+ 36 - 0
src/app/actions/app-actions/update-app-config-action.ts

@@ -0,0 +1,36 @@
+'use server';
+
+import { z } from 'zod';
+import { db } from '@/server/db';
+import { action } from '@/lib/safe-action';
+import { revalidatePath } from 'next/cache';
+import { AppServiceClass } from '@/server/services/apps/apps.service';
+import { handleActionError } from '../utils/handle-action-error';
+
+const formSchema = z.object({}).catchall(z.any());
+
+const input = z.object({
+  id: z.string(),
+  form: formSchema,
+  exposed: z.boolean().optional(),
+  domain: z.string().optional(),
+});
+
+/**
+ * Given an app id and form, updates the app config
+ */
+export const updateAppConfigAction = action(input, async ({ id, form, domain, exposed }) => {
+  try {
+    const appsService = new AppServiceClass(db);
+
+    await appsService.updateAppConfig(id, form, exposed, domain);
+
+    revalidatePath('/apps');
+    revalidatePath(`/app/${id}`);
+    revalidatePath(`/app-store/${id}`);
+
+    return { success: true };
+  } catch (e) {
+    return handleActionError(e);
+  }
+});

+ 0 - 1
src/client/modules/Apps/components/InstallForm/index.ts

@@ -1 +0,0 @@
-export { InstallForm } from './InstallForm';

+ 0 - 410
src/client/modules/Apps/containers/AppDetailsContainer/AppDetailsContainer.test.tsx

@@ -1,410 +0,0 @@
-import React from 'react';
-import { faker } from '@faker-js/faker';
-import { fireEvent, render, screen, userEvent, waitFor } from '../../../../../../tests/test-utils';
-import { createAppEntity } from '../../../../mocks/fixtures/app.fixtures';
-import { getTRPCMock, getTRPCMockError } from '../../../../mocks/getTrpcMock';
-import { server } from '../../../../mocks/server';
-import { AppDetailsContainer } from './AppDetailsContainer';
-
-describe('Test: AppDetailsContainer', () => {
-  describe('Test: UI', () => {
-    it('should render', async () => {
-      // Arrange
-      const app = createAppEntity({});
-      render(<AppDetailsContainer app={app} />);
-
-      // Assert
-      expect(screen.getByText(app.info.short_desc)).toBeInTheDocument();
-    });
-
-    it('should display update button when update is available', async () => {
-      // Arrange
-      const app = createAppEntity({ overrides: { version: 2, latestVersion: 3 } });
-      render(<AppDetailsContainer app={app} />);
-
-      // Assert
-      expect(screen.getByRole('button', { name: 'Update' })).toBeInTheDocument();
-    });
-
-    it('should display install button when app is not installed', async () => {
-      // Arrange
-      const app = createAppEntity({ overrides: { status: 'missing' } });
-
-      render(<AppDetailsContainer app={app} />);
-
-      // Assert
-      expect(screen.getByRole('button', { name: 'Install' })).toBeInTheDocument();
-    });
-
-    it('should display uninstall and start button when app is stopped', async () => {
-      // Arrange
-      const app = createAppEntity({ overrides: { status: 'stopped' } });
-
-      render(<AppDetailsContainer app={app} />);
-
-      // Assert
-      expect(screen.getByRole('button', { name: 'Remove' })).toBeInTheDocument();
-      expect(screen.getByRole('button', { name: 'Start' })).toBeInTheDocument();
-    });
-
-    it('should display stop, open and settings buttons when app is running', async () => {
-      // Arrange
-      const app = createAppEntity({ overrides: { status: 'running' } });
-      render(<AppDetailsContainer app={app} />);
-
-      // Assert
-      expect(screen.getByRole('button', { name: 'Stop' })).toBeInTheDocument();
-      expect(screen.getByRole('button', { name: 'Open' })).toBeInTheDocument();
-      expect(screen.getByRole('button', { name: 'Settings' })).toBeInTheDocument();
-    });
-
-    it('should not display update button when update is not available', async () => {
-      // Arrange
-      const app = createAppEntity({ overrides: { version: 3 }, overridesInfo: { tipi_version: 3 } });
-      render(<AppDetailsContainer app={app} />);
-
-      // Assert
-      expect(screen.queryByRole('button', { name: 'Update' })).not.toBeInTheDocument();
-    });
-
-    it('should not display open button when app has no_gui set to true', async () => {
-      // Arrange
-      const app = createAppEntity({ overridesInfo: { no_gui: true } });
-      render(<AppDetailsContainer app={app} />);
-
-      // Assert
-      expect(screen.queryByRole('button', { name: 'Open' })).not.toBeInTheDocument();
-    });
-  });
-
-  describe('Test: Open app', () => {
-    it('should call window.open with the correct url when open button is clicked', async () => {
-      // Arrange
-      const app = createAppEntity({});
-      const spy = jest.spyOn(window, 'open').mockImplementation(() => null);
-      render(<AppDetailsContainer app={app} />);
-
-      // Act
-      const openButton = screen.getByRole('button', { name: 'Open' });
-      await userEvent.type(openButton, '{arrowdown}');
-      await waitFor(() => {
-        expect(screen.getByText(/localhost:/)).toBeInTheDocument();
-      });
-
-      const openButtonItem = screen.getByText(/localhost:/);
-      await userEvent.click(openButtonItem);
-
-      // Assert
-      await waitFor(() => {
-        expect(spy).toHaveBeenCalledWith(`http://localhost:${app.info.port}`, '_blank', 'noreferrer');
-      });
-      spy.mockRestore();
-    });
-
-    it('should open with https when app info has https set to true', async () => {
-      // Arrange
-      const app = createAppEntity({ overridesInfo: { https: true } });
-      const spy = jest.spyOn(window, 'open').mockImplementation(() => null);
-      render(<AppDetailsContainer app={app} />);
-
-      // Act
-      const openButton = screen.getByRole('button', { name: 'Open' });
-      await userEvent.type(openButton, '{arrowdown}');
-
-      await waitFor(() => {
-        expect(screen.getByText(/localhost:/)).toBeInTheDocument();
-      });
-
-      const openButtonItem = screen.getByText(/localhost:/);
-      await userEvent.click(openButtonItem);
-
-      // Assert
-      await waitFor(() => {
-        expect(spy).toHaveBeenCalledWith(`https://localhost:${app.info.port}`, '_blank', 'noreferrer');
-      });
-      spy.mockRestore();
-    });
-
-    it('should open with domain when domain is clicked', async () => {
-      // Arrange
-      const app = createAppEntity({ overrides: { domain: 'test.com', exposed: true } });
-      const spy = jest.spyOn(window, 'open').mockImplementation(() => null);
-      render(<AppDetailsContainer app={app} />);
-
-      // Act
-      const openButton = screen.getByRole('button', { name: 'Open' });
-      await userEvent.type(openButton, '{arrowdown}');
-
-      await waitFor(() => {
-        expect(screen.getByText(/test.com/)).toBeInTheDocument();
-      });
-
-      const openButtonItem = screen.getByText(/test.com/);
-      await userEvent.click(openButtonItem);
-
-      // Assert
-      await waitFor(() => {
-        expect(spy).toHaveBeenCalledWith(`https://test.com`, '_blank', 'noreferrer');
-      });
-      spy.mockRestore();
-    });
-
-    it('should open with local domain when local domain is clicked', async () => {
-      // Arrange
-      const app = createAppEntity({});
-      const spy = jest.spyOn(window, 'open').mockImplementation(() => null);
-      render(<AppDetailsContainer app={app} />);
-
-      // Act
-      const openButton = screen.getByRole('button', { name: 'Open' });
-      await userEvent.type(openButton, '{arrowdown}');
-
-      await waitFor(() => {
-        expect(screen.getByText(/.tipi.lan/)).toBeInTheDocument();
-      });
-
-      const openButtonItem = screen.getByText(/.tipi.lan/);
-      await userEvent.click(openButtonItem);
-
-      // Assert
-      await waitFor(() => {
-        expect(spy).toHaveBeenCalledWith(`https://${app.id}.tipi.lan`, '_blank', 'noreferrer');
-      });
-      spy.mockRestore();
-    });
-  });
-
-  describe('Test: Install app', () => {
-    it('should display toast success when install success', async () => {
-      // Arrange
-      const app = createAppEntity({ overrides: { status: 'missing' } });
-      server.use(getTRPCMock({ path: ['app', 'installApp'], type: 'mutation', response: app }));
-      render(<AppDetailsContainer app={app} />);
-      const openModalButton = screen.getByRole('button', { name: 'Install' });
-      fireEvent.click(openModalButton);
-
-      // Act
-      const installButton = screen.getByRole('button', { name: 'Install' });
-      fireEvent.click(installButton);
-
-      await waitFor(() => {
-        expect(screen.getByText('App installed successfully')).toBeInTheDocument();
-      });
-    });
-
-    it('should display a toast error when install mutation fails', async () => {
-      // Arrange
-      const error = faker.lorem.sentence();
-      server.use(
-        getTRPCMockError({
-          path: ['app', 'installApp'],
-          type: 'mutation',
-          message: error,
-        }),
-      );
-
-      const app = createAppEntity({ overrides: { status: 'missing' } });
-      render(<AppDetailsContainer app={app} />);
-      const openModalButton = screen.getByRole('button', { name: 'Install' });
-      fireEvent.click(openModalButton);
-
-      // Act
-      const installButton = screen.getByRole('button', { name: 'Install' });
-      fireEvent.click(installButton);
-
-      await waitFor(() => {
-        expect(screen.getByText(error)).toBeInTheDocument();
-      });
-    });
-  });
-
-  describe('Test: Update app', () => {
-    it('should display toast success when update success', async () => {
-      // Arrange
-      const app = createAppEntity({ overrides: { version: 2, latestVersion: 3 } });
-      server.use(getTRPCMock({ path: ['app', 'updateApp'], type: 'mutation', response: app }));
-      render(<AppDetailsContainer app={app} />);
-      const openModalButton = screen.getByRole('button', { name: 'Update' });
-      fireEvent.click(openModalButton);
-
-      // Act
-      const modalUpdateButton = screen.getByRole('button', { name: 'Update' });
-      modalUpdateButton.click();
-
-      await waitFor(() => {
-        expect(screen.getByText('App updated successfully')).toBeInTheDocument();
-      });
-    });
-
-    it('should display a toast error when update mutation fails', async () => {
-      // Arrange
-      const error = faker.lorem.sentence();
-      server.use(getTRPCMockError({ path: ['app', 'updateApp'], type: 'mutation', message: error }));
-      const app = createAppEntity({ overrides: { version: 2, latestVersion: 3 }, overridesInfo: { tipi_version: 3 } });
-      render(<AppDetailsContainer app={app} />);
-      const openModalButton = screen.getByRole('button', { name: 'Update' });
-      fireEvent.click(openModalButton);
-
-      // Act
-      const modalUpdateButton = screen.getByRole('button', { name: 'Update' });
-      modalUpdateButton.click();
-
-      // Assert
-      await waitFor(() => {
-        expect(screen.getByText(error)).toBeInTheDocument();
-      });
-    });
-  });
-
-  describe('Test: Uninstall app', () => {
-    it('should display toast success when uninstall success', async () => {
-      // Arrange
-      const app = createAppEntity({ status: 'stopped' });
-      server.use(getTRPCMock({ path: ['app', 'uninstallApp'], type: 'mutation', response: { id: app.id, config: {}, status: 'missing' } }));
-      render(<AppDetailsContainer app={app} />);
-      const openModalButton = screen.getByRole('button', { name: 'Remove' });
-      fireEvent.click(openModalButton);
-
-      // Act
-      const modalUninstallButton = screen.getByRole('button', { name: 'Uninstall' });
-      modalUninstallButton.click();
-
-      // Assert
-      await waitFor(() => {
-        expect(screen.getByText('App uninstalled successfully')).toBeInTheDocument();
-      });
-    });
-
-    it('should display a toast error when uninstall mutation fails', async () => {
-      // Arrange
-      const error = faker.lorem.sentence();
-      server.use(getTRPCMockError({ path: ['app', 'uninstallApp'], type: 'mutation', message: error }));
-      const app = createAppEntity({ status: 'stopped' });
-      render(<AppDetailsContainer app={app} />);
-      const openModalButton = screen.getByRole('button', { name: 'Remove' });
-      fireEvent.click(openModalButton);
-
-      // Act
-      const modalUninstallButton = screen.getByRole('button', { name: 'Uninstall' });
-      modalUninstallButton.click();
-
-      // Assert
-      await waitFor(() => {
-        expect(screen.getByText(error)).toBeInTheDocument();
-      });
-    });
-  });
-
-  describe('Test: Start app', () => {
-    it('should display toast success when start success', async () => {
-      // Arrange
-      const app = createAppEntity({ status: 'stopped' });
-      server.use(getTRPCMock({ path: ['app', 'startApp'], type: 'mutation', response: app }));
-      render(<AppDetailsContainer app={app} />);
-
-      // Act
-      const startButton = screen.getByRole('button', { name: 'Start' });
-      startButton.click();
-
-      // Assert
-      await waitFor(() => {
-        expect(screen.getByText('App started successfully')).toBeInTheDocument();
-      });
-    });
-
-    it('should display a toast error when start mutation fails', async () => {
-      // Arrange
-      const error = faker.lorem.sentence();
-      server.use(getTRPCMockError({ path: ['app', 'startApp'], type: 'mutation', message: error }));
-      const app = createAppEntity({ status: 'stopped' });
-      render(<AppDetailsContainer app={app} />);
-
-      // Act
-      const startButton = screen.getByRole('button', { name: 'Start' });
-      startButton.click();
-
-      // Assert
-      await waitFor(() => {
-        expect(screen.getByText(error)).toBeInTheDocument();
-      });
-    });
-  });
-
-  describe('Test: Stop app', () => {
-    it('should display toast success when stop success', async () => {
-      // Arrange
-      const app = createAppEntity({ status: 'running' });
-      server.use(getTRPCMock({ path: ['app', 'stopApp'], type: 'mutation', response: app }));
-      render(<AppDetailsContainer app={app} />);
-      const openModalButton = screen.getByRole('button', { name: 'Stop' });
-      fireEvent.click(openModalButton);
-
-      // Act
-      const modalStopButton = screen.getByRole('button', { name: 'Stop' });
-      modalStopButton.click();
-
-      // Assert
-      await waitFor(() => {
-        expect(screen.getByText('App stopped successfully')).toBeInTheDocument();
-      });
-    });
-
-    it('should display a toast error when stop mutation fails', async () => {
-      // Arrange
-      const error = faker.lorem.sentence();
-      server.use(getTRPCMockError({ path: ['app', 'stopApp'], type: 'mutation', message: error }));
-      const app = createAppEntity({ status: 'running' });
-      render(<AppDetailsContainer app={app} />);
-      const openModalButton = screen.getByRole('button', { name: 'Stop' });
-      fireEvent.click(openModalButton);
-
-      // Act
-      const modalStopButton = screen.getByRole('button', { name: 'Stop' });
-      modalStopButton.click();
-
-      // Assert
-      await waitFor(() => {
-        expect(screen.getByText(error)).toBeInTheDocument();
-      });
-    });
-  });
-
-  describe('Test: Update app config', () => {
-    it('should display toast success when update config success', async () => {
-      // Arrange
-      const app = createAppEntity({ status: 'running', overridesInfo: { exposable: true } });
-      server.use(getTRPCMock({ path: ['app', 'updateAppConfig'], type: 'mutation', response: app }));
-      render(<AppDetailsContainer app={app} />);
-      const openModalButton = screen.getByRole('button', { name: 'Settings' });
-      fireEvent.click(openModalButton);
-
-      // Act
-      const configButton = screen.getByRole('button', { name: 'Update' });
-      configButton.click();
-
-      // Assert
-      await waitFor(() => {
-        expect(screen.getByText('App config updated successfully. Restart the app to apply the changes')).toBeInTheDocument();
-      });
-    });
-
-    it('should display a toast error when update config mutation fails', async () => {
-      // Arrange
-      const error = faker.lorem.sentence();
-      server.use(getTRPCMockError({ path: ['app', 'updateAppConfig'], type: 'mutation', message: error }));
-      const app = createAppEntity({ status: 'running', overridesInfo: { exposable: true } });
-      render(<AppDetailsContainer app={app} />);
-      const openModalButton = screen.getByRole('button', { name: 'Settings' });
-      fireEvent.click(openModalButton);
-
-      // Act
-      const configButton = screen.getByRole('button', { name: 'Update' });
-      configButton.click();
-
-      // Assert
-      await waitFor(() => {
-        expect(screen.getByText(error)).toBeInTheDocument();
-      });
-    });
-  });
-});

+ 0 - 208
src/client/modules/Apps/containers/AppDetailsContainer/AppDetailsContainer.tsx

@@ -1,208 +0,0 @@
-import React from 'react';
-import { toast } from 'react-hot-toast';
-import { useTranslations } from 'next-intl';
-import type { MessageKey } from '@/server/utils/errors';
-import { useDisclosure } from '../../../../hooks/useDisclosure';
-import { AppLogo } from '../../../../components/AppLogo/AppLogo';
-import { AppStatus } from '../../../../components/AppStatus';
-import { AppActions } from '../../components/AppActions';
-import { AppDetailsTabs } from '../../components/AppDetailsTabs';
-import { InstallModal } from '../../components/InstallModal';
-import { StopModal } from '../../components/StopModal';
-import { UninstallModal } from '../../components/UninstallModal';
-import { UpdateModal } from '../../components/UpdateModal';
-import { UpdateSettingsModal } from '../../components/UpdateSettingsModal';
-import { FormValues } from '../../components/InstallForm/InstallForm';
-import { trpc } from '../../../../utils/trpc';
-import { AppRouterOutput } from '../../../../../server/routers/app/app.router';
-import { castAppConfig } from '../../helpers/castAppConfig';
-
-interface IProps {
-  app: AppRouterOutput['getApp'];
-}
-type OpenType = 'local' | 'domain' | 'local_domain';
-
-export const AppDetailsContainer: React.FC<IProps> = ({ app }) => {
-  const t = useTranslations();
-  const installDisclosure = useDisclosure();
-  const uninstallDisclosure = useDisclosure();
-  const stopDisclosure = useDisclosure();
-  const updateDisclosure = useDisclosure();
-  const updateSettingsDisclosure = useDisclosure();
-
-  const getSettings = trpc.system.getSettings.useQuery();
-
-  const utils = trpc.useContext();
-
-  const invalidate = () => {
-    utils.app.installedApps.invalidate();
-    utils.app.getApp.invalidate({ id: app.id });
-  };
-
-  const install = trpc.app.installApp.useMutation({
-    onMutate: () => {
-      utils.app.getApp.setData({ id: app.id }, { ...app, status: 'installing' });
-      installDisclosure.close();
-    },
-    onSuccess: () => {
-      invalidate();
-      toast.success(t('apps.app-details.install-success'));
-    },
-    onError: (e) => {
-      invalidate();
-      toast.error(t(e.data?.tError.message as MessageKey, { ...e.data?.tError?.variables }));
-    },
-  });
-
-  const uninstall = trpc.app.uninstallApp.useMutation({
-    onMutate: () => {
-      utils.app.getApp.setData({ id: app.id }, { ...app, status: 'uninstalling' });
-      uninstallDisclosure.close();
-    },
-    onSuccess: () => {
-      invalidate();
-      toast.success(t('apps.app-details.uninstall-success'));
-    },
-    onError: (e) => toast.error(t(e.data?.tError.message as MessageKey, { ...e.data?.tError?.variables })),
-  });
-
-  const stop = trpc.app.stopApp.useMutation({
-    onMutate: () => {
-      utils.app.getApp.setData({ id: app.id }, { ...app, status: 'stopping' });
-      stopDisclosure.close();
-    },
-    onSuccess: () => {
-      invalidate();
-      toast.success(t('apps.app-details.stop-success'));
-    },
-    onError: (e) => toast.error(t(e.data?.tError.message as MessageKey, { ...e.data?.tError?.variables })),
-  });
-
-  const update = trpc.app.updateApp.useMutation({
-    onMutate: () => {
-      utils.app.getApp.setData({ id: app.id }, { ...app, status: 'updating' });
-      updateDisclosure.close();
-    },
-    onSuccess: () => {
-      invalidate();
-      toast.success(t('apps.app-details.update-success'));
-    },
-    onError: (e) => toast.error(t(e.data?.tError.message as MessageKey, { ...e.data?.tError?.variables })),
-  });
-
-  const start = trpc.app.startApp.useMutation({
-    onMutate: () => {
-      utils.app.getApp.setData({ id: app.id }, { ...app, status: 'starting' });
-    },
-    onSuccess: () => {
-      invalidate();
-      toast.success(t('apps.app-details.start-success'));
-    },
-    onError: (e) => toast.error(t(e.data?.tError.message as MessageKey, { ...e.data?.tError?.variables })),
-  });
-
-  const updateConfig = trpc.app.updateAppConfig.useMutation({
-    onMutate: () => updateSettingsDisclosure.close(),
-    onSuccess: () => {
-      invalidate();
-      toast.success(t('apps.app-details.update-config-success'));
-    },
-    onError: (e) => toast.error(t(e.data?.tError.message as MessageKey, { ...e.data?.tError?.variables })),
-  });
-
-  const updateAvailable = Number(app.version || 0) < Number(app?.latestVersion || 0);
-
-  const handleInstallSubmit = async (values: FormValues) => {
-    const { exposed, domain } = values;
-    install.mutate({ id: app.id, form: values, exposed, domain });
-  };
-
-  const handleUnistallSubmit = () => {
-    uninstall.mutate({ id: app.id });
-  };
-
-  const handleStopSubmit = () => {
-    stop.mutate({ id: app.id });
-  };
-
-  const handleStartSubmit = async () => {
-    start.mutate({ id: app.id });
-  };
-
-  const handleUpdateSettingsSubmit = async (values: FormValues) => {
-    const { exposed, domain } = values;
-    updateConfig.mutate({ id: app.id, form: values, exposed, domain });
-  };
-
-  const handleUpdateSubmit = async () => {
-    update.mutate({ id: app.id });
-  };
-
-  const handleOpen = (type: OpenType) => {
-    let url = '';
-    const { https } = app.info;
-    const protocol = https ? 'https' : 'http';
-
-    if (typeof window !== 'undefined') {
-      // Current domain
-      const domain = window.location.hostname;
-      url = `${protocol}://${domain}:${app.info.port}${app.info.url_suffix || ''}`;
-    }
-
-    if (type === 'domain' && app.domain) {
-      url = `https://${app.domain}${app.info.url_suffix || ''}`;
-    }
-
-    if (type === 'local_domain') {
-      url = `https://${app.id}.${getSettings.data?.localDomain}`;
-    }
-
-    window.open(url, '_blank', 'noreferrer');
-  };
-
-  const newVersion = [app?.latestDockerVersion ? `${app?.latestDockerVersion}` : '', `(${String(app?.latestVersion)})`].join(' ');
-
-  return (
-    <div className="card" data-testid="app-details">
-      <InstallModal onSubmit={handleInstallSubmit} isOpen={installDisclosure.isOpen} onClose={installDisclosure.close} info={app.info} />
-      <StopModal onConfirm={handleStopSubmit} isOpen={stopDisclosure.isOpen} onClose={stopDisclosure.close} info={app.info} />
-      <UninstallModal onConfirm={handleUnistallSubmit} isOpen={uninstallDisclosure.isOpen} onClose={uninstallDisclosure.close} info={app.info} />
-      <UpdateModal onConfirm={handleUpdateSubmit} isOpen={updateDisclosure.isOpen} onClose={updateDisclosure.close} info={app.info} newVersion={newVersion} />
-      <UpdateSettingsModal
-        onSubmit={handleUpdateSettingsSubmit}
-        isOpen={updateSettingsDisclosure.isOpen}
-        onClose={updateSettingsDisclosure.close}
-        info={app.info}
-        config={castAppConfig(app?.config)}
-        exposed={app?.exposed}
-        domain={app?.domain || ''}
-      />
-      <div className="card-header d-flex flex-column flex-md-row">
-        <AppLogo id={app.id} size={130} alt={app.info.name} />
-        <div className="w-100 d-flex flex-column ms-md-3 align-items-center align-items-md-start">
-          <div>
-            <span className="mt-1 me-1">{t('apps.app-details.version')}: </span>
-            <span className="badge bg-gray mt-2">{app.info.version}</span>
-          </div>
-          <span className="mt-1 text-muted text-center mb-2">{app.info.short_desc}</span>
-          <div className="mb-1">{app.status !== 'missing' && <AppStatus status={app.status} />}</div>
-          <AppActions
-            localDomain={getSettings.data?.localDomain}
-            updateAvailable={updateAvailable}
-            onUpdate={updateDisclosure.open}
-            onUpdateSettings={updateSettingsDisclosure.open}
-            onStop={stopDisclosure.open}
-            onCancel={stopDisclosure.open}
-            onUninstall={uninstallDisclosure.open}
-            onInstall={installDisclosure.open}
-            onOpen={handleOpen}
-            onStart={handleStartSubmit}
-            app={app}
-            status={app.status}
-          />
-        </div>
-      </div>
-      <AppDetailsTabs info={app.info} />
-    </div>
-  );
-};

+ 0 - 58
src/client/modules/Apps/pages/AppDetailsPage/AppDetailsPage.test.tsx

@@ -1,58 +0,0 @@
-import React from 'react';
-import { render, screen, waitFor } from '../../../../../../tests/test-utils';
-import { AppWithInfo } from '../../../../core/types';
-import { createAppEntity } from '../../../../mocks/fixtures/app.fixtures';
-import { getTRPCMock } from '../../../../mocks/getTrpcMock';
-import { server } from '../../../../mocks/server';
-import { AppDetailsPage } from './AppDetailsPage';
-
-describe('AppDetailsPage', () => {
-  it('should render', async () => {
-    // Arrange
-    render(<AppDetailsPage appId="nothing" />);
-
-    // Assert
-    await waitFor(() => {
-      expect(screen.getByTestId('app-details')).toBeInTheDocument();
-    });
-  });
-
-  it('should set the breadcrumb prop of the Layout component to an array containing two elements with the correct name and href properties', async () => {
-    // Arrange
-    const app = createAppEntity({}) as AppWithInfo;
-    server.use(
-      getTRPCMock({
-        path: ['app', 'getApp'],
-        response: app,
-      }),
-    );
-
-    jest.mock('next/router', () => {
-      const actualRouter = jest.requireActual('next-router-mock');
-
-      return {
-        ...actualRouter,
-        useRouter: () => ({
-          ...actualRouter.useRouter(),
-          pathname: `/apps/${app.id}`,
-        }),
-      };
-    });
-
-    render(<AppDetailsPage appId={app.id} />);
-    await waitFor(() => {
-      expect(screen.getByTestId('app-details')).toBeInTheDocument();
-    });
-
-    // Act
-    const breadcrumbs = await screen.findAllByTestId('breadcrumb-item');
-    const breadcrumbsLinks = await screen.findAllByTestId('breadcrumb-link');
-
-    // Assert
-    expect(breadcrumbs[0]).toHaveTextContent('Apps');
-    expect(breadcrumbsLinks[0]).toHaveAttribute('href', '/apps');
-
-    expect(breadcrumbs[1]).toHaveTextContent(app.info.name);
-    expect(breadcrumbsLinks[1]).toHaveAttribute('href', `/apps/${app.id}`);
-  });
-});

+ 0 - 42
src/client/modules/Apps/pages/AppDetailsPage/AppDetailsPage.tsx

@@ -1,42 +0,0 @@
-import { NextPage } from 'next';
-import React from 'react';
-import { useRouter } from 'next/router';
-import { useTranslations } from 'next-intl';
-import type { MessageKey } from '@/server/utils/errors';
-import { Layout } from '../../../../components/Layout';
-import { ErrorPage } from '../../../../components/ui/ErrorPage';
-import { trpc } from '../../../../utils/trpc';
-import { AppDetailsContainer } from '../../containers/AppDetailsContainer/AppDetailsContainer';
-
-interface IProps {
-  appId: string;
-}
-
-type Path = { refSlug: string; refTitle: string };
-const paths: Record<string, Path> = {
-  'app-store': { refSlug: 'app-store', refTitle: 'App Store' },
-  apps: { refSlug: 'apps', refTitle: 'Apps' },
-};
-
-export const AppDetailsPage: NextPage<IProps> = ({ appId }) => {
-  const router = useRouter();
-  const t = useTranslations();
-
-  const basePath = router.pathname.split('/').slice(1)[0];
-  const { refSlug, refTitle } = paths[basePath || 'apps'] || { refSlug: 'apps', refTitle: 'Apps' };
-
-  const { data, error } = trpc.app.getApp.useQuery({ id: appId });
-
-  const breadcrumb = [
-    { name: refTitle, href: `/${refSlug}` },
-    { name: data?.info?.name || '', href: `/${refSlug}/${data?.id}`, current: true },
-  ];
-
-  // TODO: add loading state
-  return (
-    <Layout title={data?.info.name || ''} breadcrumbs={breadcrumb}>
-      {data?.info && <AppDetailsContainer app={data} />}
-      {error && <ErrorPage error={t(error.data?.tError.message as MessageKey, { ...error.data?.tError.variables })} />}
-    </Layout>
-  );
-};

+ 0 - 1
src/client/modules/Apps/pages/AppDetailsPage/index.ts

@@ -1 +0,0 @@
-export { AppDetailsPage } from './AppDetailsPage';

+ 5 - 5
src/client/modules/Settings/components/OtpForm/OptForm.test.tsx

@@ -28,7 +28,7 @@ describe('<OtpForm />', () => {
 
   it('should prompt for password when disabling 2FA', async () => {
     // arrange
-    server.use(getTRPCMock({ path: ['auth', 'me'], response: { totpEnabled: true, id: 12, username: 'test', locale: 'en' } }));
+    server.use(getTRPCMock({ path: ['auth', 'me'], response: { totpEnabled: true, id: 12, username: 'test', locale: 'en', operator: true } }));
     render(<OtpForm />);
     const twoFactorAuthButton = screen.getByRole('switch', { name: /Enable two-factor authentication/i });
     await waitFor(() => {
@@ -46,7 +46,7 @@ describe('<OtpForm />', () => {
 
   it('should show show error toast if password is incorrect while enabling 2FA', async () => {
     // arrange
-    server.use(getTRPCMock({ path: ['auth', 'me'], response: { totpEnabled: false, id: 12, username: 'test', locale: 'en' } }));
+    server.use(getTRPCMock({ path: ['auth', 'me'], response: { totpEnabled: false, id: 12, username: 'test', locale: 'en', operator: true } }));
     server.use(getTRPCMockError({ path: ['auth', 'getTotpUri'], type: 'mutation', message: 'Invalid password' }));
     render(<OtpForm />);
     const twoFactorAuthButton = screen.getByRole('switch', { name: /Enable two-factor authentication/i });
@@ -74,7 +74,7 @@ describe('<OtpForm />', () => {
 
   it('should show show error toast if password is incorrect while disabling 2FA', async () => {
     // arrange
-    server.use(getTRPCMock({ path: ['auth', 'me'], response: { locale: 'en', totpEnabled: true, id: 12, username: 'test' } }));
+    server.use(getTRPCMock({ path: ['auth', 'me'], response: { locale: 'en', totpEnabled: true, id: 12, username: 'test', operator: true } }));
     server.use(getTRPCMockError({ path: ['auth', 'disableTotp'], type: 'mutation', message: 'Invalid password' }));
     render(<OtpForm />);
 
@@ -103,7 +103,7 @@ describe('<OtpForm />', () => {
 
   it('should show success toast if password is correct while disabling 2FA', async () => {
     // arrange
-    server.use(getTRPCMock({ path: ['auth', 'me'], response: { locale: 'en', totpEnabled: true, id: 12, username: 'test' } }));
+    server.use(getTRPCMock({ path: ['auth', 'me'], response: { locale: 'en', totpEnabled: true, id: 12, username: 'test', operator: true } }));
     server.use(getTRPCMock({ path: ['auth', 'disableTotp'], type: 'mutation', response: true }));
 
     render(<OtpForm />);
@@ -262,7 +262,7 @@ describe('<OtpForm />', () => {
 
   it('can close the disable modal by clicking on the esc key', async () => {
     // arrange
-    server.use(getTRPCMock({ path: ['auth', 'me'], response: { locale: 'en', totpEnabled: true, username: '', id: 1 } }));
+    server.use(getTRPCMock({ path: ['auth', 'me'], response: { locale: 'en', totpEnabled: true, username: '', id: 1, operator: true } }));
     render(<OtpForm />);
     const twoFactorAuthButton = screen.getByRole('switch', { name: /Enable two-factor authentication/i });
     await waitFor(() => {

+ 0 - 0
src/client/modules/Apps/helpers/castAppConfig.ts → src/lib/helpers/castAppConfig.ts


+ 0 - 19
src/pages/app-store/[id].tsx

@@ -1,19 +0,0 @@
-import { getAuthedPageProps, getMessagesPageProps } from '@/utils/page-helpers';
-import merge from 'lodash.merge';
-import { GetServerSideProps } from 'next';
-
-export { AppDetailsPage as default } from '../../client/modules/Apps/pages/AppDetailsPage';
-
-export const getServerSideProps: GetServerSideProps = async (ctx) => {
-  const authedProps = await getAuthedPageProps(ctx);
-  const messagesProps = await getMessagesPageProps(ctx);
-
-  const { id } = ctx.query;
-  const appId = String(id);
-
-  return merge(authedProps, messagesProps, {
-    props: {
-      appId,
-    },
-  });
-};

+ 1 - 1
src/server/services/apps/apps.service.test.ts

@@ -2,8 +2,8 @@ import fs from 'fs-extra';
 import waitForExpect from 'wait-for-expect';
 import { TestDatabase, clearDatabase, closeDatabase, createDatabase } from '@/server/tests/test-utils';
 import { faker } from '@faker-js/faker';
-import { castAppConfig } from '@/client/modules/Apps/helpers/castAppConfig';
 import { waitUntilFinishedMock } from '@/tests/server/jest.setup';
+import { castAppConfig } from '@/lib/helpers/castAppConfig';
 import { AppServiceClass } from './apps.service';
 import { EventDispatcher } from '../../core/EventDispatcher';
 import { getAllApps, getAppById, updateApp, createAppConfig, insertApp } from '../../tests/apps.factory';

+ 1 - 1
src/server/services/apps/apps.service.ts

@@ -3,9 +3,9 @@ import { App } from '@/server/db/schema';
 import { AppQueries } from '@/server/queries/apps/apps.queries';
 import { TranslatedError } from '@/server/utils/errors';
 import { Database } from '@/server/db';
-import { castAppConfig } from '@/client/modules/Apps/helpers/castAppConfig';
 import { AppInfo } from '@runtipi/shared';
 import { EventDispatcher } from '@/server/core/EventDispatcher/EventDispatcher';
+import { castAppConfig } from '@/lib/helpers/castAppConfig';
 import { checkAppRequirements, getAvailableApps, getAppInfo, getUpdateInfo } from './apps.helpers';
 import { getConfig } from '../../core/TipiConfig';
 import { Logger } from '../../core/Logger';