Преглед изворни кода

feat(apps): add "enable on guest dashboard option"

Nicolas Meienberger пре 1 година
родитељ
комит
5830d16382

+ 0 - 1
next-env.d.ts

@@ -1,6 +1,5 @@
 /// <reference types="next" />
 /// <reference types="next/image-types/global" />
-/// <reference types="next/navigation-types/compat/navigation" />
 
 // NOTE: This file should not be edited
 // see https://nextjs.org/docs/basic-features/typescript for more information.

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

@@ -116,8 +116,7 @@ export const AppDetailsContainer: React.FC<IProps> = ({ app, localDomain }) => {
   const handleInstallSubmit = async (values: FormValues) => {
     setCustomStatus('installing');
     installDisclosure.close();
-    const { exposed, domain } = values;
-    installMutation.execute({ id: app.id, form: values, exposed, domain });
+    installMutation.execute({ id: app.id, form: values });
   };
 
   const handleUnistallSubmit = () => {
@@ -139,8 +138,7 @@ export const AppDetailsContainer: React.FC<IProps> = ({ app, localDomain }) => {
 
   const handleUpdateSettingsSubmit = async (values: FormValues) => {
     updateSettingsDisclosure.close();
-    const { exposed, domain } = values;
-    updateConfigMutation.execute({ id: app.id, form: values, exposed, domain });
+    updateConfigMutation.execute({ id: app.id, form: values });
   };
 
   const handleUpdateSubmit = async () => {
@@ -185,8 +183,6 @@ export const AppDetailsContainer: React.FC<IProps> = ({ app, localDomain }) => {
         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} />

+ 11 - 2
src/app/(dashboard)/app-store/[id]/components/InstallForm/InstallForm.tsx

@@ -14,7 +14,7 @@ import { validateAppConfig } from '../../utils/validators';
 interface IProps {
   formFields: FormField[];
   onSubmit: (values: FormValues) => void;
-  initalValues?: { exposed?: boolean; domain?: string } & { [key: string]: string | boolean | undefined };
+  initalValues?: { [key: string]: unknown };
   info: AppInfo;
   loading?: boolean;
 }
@@ -22,6 +22,7 @@ interface IProps {
 export type FormValues = {
   exposed?: boolean;
   domain?: string;
+  isVisibleOnGuestDashboard?: boolean;
   [key: string]: string | boolean | undefined;
 };
 
@@ -50,7 +51,7 @@ export const InstallForm: React.FC<IProps> = ({ formFields, info, onSubmit, init
   useEffect(() => {
     if (initalValues && !isDirty) {
       Object.entries(initalValues).forEach(([key, value]) => {
-        setValue(key, value);
+        setValue(key, value as string);
       });
     }
   }, [initalValues, isDirty, setValue]);
@@ -153,6 +154,14 @@ export const InstallForm: React.FC<IProps> = ({ formFields, info, onSubmit, init
   return (
     <form className="flex flex-col" onSubmit={handleSubmit(validate)}>
       {formFields.filter(typeFilter).map(renderField)}
+      <Controller
+        control={control}
+        name="isVisibleOnGuestDashboard"
+        defaultValue={false}
+        render={({ field: { onChange, value, ref, ...props } }) => (
+          <Switch className="mb-3" disabled={info.force_expose} ref={ref} checked={value} onCheckedChange={onChange} {...props} label={t('display-on-guest-dashboard')} />
+        )}
+      />
       {info.exposable && renderExposeForm()}
       <Button loading={loading} type="submit" className="btn-success">
         {initalValues ? t('submit-update') : t('sumbit-install')}

+ 2 - 4
src/app/(dashboard)/app-store/[id]/components/UpdateSettingsModal/UpdateSettingsModal.tsx

@@ -9,13 +9,11 @@ interface IProps {
   info: AppInfo;
   config: Record<string, unknown>;
   isOpen: boolean;
-  exposed?: boolean;
-  domain?: string;
   onClose: () => void;
   onSubmit: (values: FormValues) => void;
 }
 
-export const UpdateSettingsModal: React.FC<IProps> = ({ info, config, isOpen, onClose, onSubmit, exposed, domain }) => {
+export const UpdateSettingsModal: React.FC<IProps> = ({ info, config, isOpen, onClose, onSubmit }) => {
   const t = useTranslations('apps.app-details.update-settings-form');
 
   return (
@@ -26,7 +24,7 @@ export const UpdateSettingsModal: React.FC<IProps> = ({ info, config, isOpen, on
         </DialogHeader>
         <ScrollArea maxHeight={500}>
           <DialogDescription>
-            <InstallForm onSubmit={onSubmit} formFields={info.form_fields} info={info} initalValues={{ ...config, exposed, domain }} />
+            <InstallForm onSubmit={onSubmit} formFields={info.form_fields} info={info} initalValues={{ ...config }} />
           </DialogDescription>
         </ScrollArea>
       </DialogContent>

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

@@ -6,8 +6,9 @@ import React from 'react';
 import { useTranslations } from 'next-intl';
 import { AppCategory } from '@runtipi/shared';
 import { AppLogo } from '@/components/AppLogo';
+import { limitText } from '@/lib/helpers/text-helpers';
 import styles from './AppStoreTile.module.scss';
-import { colorSchemeForCategory, limitText } from '../../helpers/table.helpers';
+import { colorSchemeForCategory } from '../../helpers/table.helpers';
 
 type App = {
   id: string;

+ 2 - 1
src/app/(dashboard)/app-store/helpers/__tests__/table.helpers.test.ts

@@ -1,5 +1,6 @@
+import { limitText } from '@/lib/helpers/text-helpers';
 import { createAppConfig } from '../../../../../server/tests/apps.factory';
-import { limitText, sortTable } from '../table.helpers';
+import { sortTable } from '../table.helpers';
 import { AppTableData } from '../table.types';
 
 describe('sortTable function', () => {

+ 0 - 2
src/app/(dashboard)/app-store/helpers/table.helpers.ts

@@ -46,8 +46,6 @@ export const sortTable = (params: SortParams) => {
   return sortedData.filter((app) => app.name.toLowerCase().includes(search.toLowerCase()));
 };
 
-export const limitText = (text: string, limit: number) => (text.length > limit ? `${text.substring(0, limit)}...` : text);
-
 export const colorSchemeForCategory: Record<AppCategory, string> = {
   network: 'blue',
   media: 'azure',

+ 2 - 4
src/app/actions/app-actions/install-app-action.ts

@@ -12,18 +12,16 @@ 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 }) => {
+export const installAppAction = action(input, async ({ id, form }) => {
   try {
     const appsService = new AppServiceClass(db);
 
-    await appsService.installApp(id, form, exposed, domain);
+    await appsService.installApp(id, form);
 
     revalidatePath('/apps');
     revalidatePath(`/app/${id}`);

+ 2 - 2
src/app/actions/app-actions/update-app-config-action.ts

@@ -19,11 +19,11 @@ const input = z.object({
 /**
  * Given an app id and form, updates the app config
  */
-export const updateAppConfigAction = action(input, async ({ id, form, domain, exposed }) => {
+export const updateAppConfigAction = action(input, async ({ id, form }) => {
   try {
     const appsService = new AppServiceClass(db);
 
-    await appsService.updateAppConfig(id, form, exposed, domain);
+    await appsService.updateAppConfig(id, form);
 
     revalidatePath('/apps');
     revalidatePath(`/app/${id}`);

+ 9 - 1
src/client/messages/en.json

@@ -177,6 +177,7 @@
       "install-form": {
         "title": "Install {name}",
         "expose-app": "Expose app",
+        "display-on-guest-dashboard": "Display on guest dashboard",
         "domain-name": "Domain name",
         "domain-name-hint": "Make sure this exact domain contains an A record pointing to your IP.",
         "choose-option": "Choose an option...",
@@ -239,6 +240,8 @@
       "invalid-ip": "Invalid IP address",
       "invalid-url": "Invalid URL",
       "invalid-domain": "Invalid domain",
+      "guest-dashboard": "Enable guest dashboard",
+      "guest-dashboard-hint": "This will allow non-authenticated users to see a limited dashboard and easily access the running apps on your instance.",
       "domain-name": "Domain name",
       "domain-name-hint": "Make sure this exact domain contains an A record pointing to your IP.",
       "dns-ip": "DNS IP",
@@ -291,10 +294,15 @@
     "app-store": "App Store",
     "settings": "Settings",
     "logout": "Logout",
+    "login": "Login",
     "dark-mode": "Dark Mode",
     "light-mode": "Light Mode",
     "sponsor": "Sponsor",
     "source-code": "Source code",
     "update-available": "Update available"
-  }
+  },
+  "runtipi": "Runtipi",
+  "guest-dashboard": "Guest dashboard",
+  "guest-dashboard-no-apps": "No apps to display",
+  "guest-dashboard-no-apps-subtitle": "Ask your administrator to add apps to the guest dashboard or login to see your apps."
 }

+ 1 - 0
src/lib/helpers/text-helpers.ts

@@ -0,0 +1 @@
+export const limitText = (text: string, limit: number) => (text.length > limit ? `${text.substring(0, limit)}...` : text);

+ 1 - 0
src/server/db/schema.ts

@@ -48,6 +48,7 @@ export const appTable = pgTable('app', {
   version: integer('version').default(1).notNull(),
   exposed: boolean('exposed').notNull(),
   domain: varchar('domain'),
+  isVisibleOnGuestDashboard: boolean('is_visible_on_guest_dashboard').default(false).notNull(),
 });
 export type App = InferModel<typeof appTable>;
 export type NewApp = InferModel<typeof appTable, 'insert'>;

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

@@ -76,7 +76,7 @@ describe('Install app', () => {
     const appConfig = createAppConfig({ exposable: true });
 
     // act & assert
-    await expect(AppsService.installApp(appConfig.id, {}, true)).rejects.toThrowError('server-messages.errors.domain-required-if-expose-app');
+    await expect(AppsService.installApp(appConfig.id, { exposed: true })).rejects.toThrowError('server-messages.errors.domain-required-if-expose-app');
   });
 
   it('Should throw if app is exposed and config does not allow it', async () => {
@@ -84,7 +84,7 @@ describe('Install app', () => {
     const appConfig = createAppConfig({ exposable: false });
 
     // act & assert
-    await expect(AppsService.installApp(appConfig.id, {}, true, 'test.com')).rejects.toThrowError('server-messages.errors.app-not-exposable');
+    await expect(AppsService.installApp(appConfig.id, { exposed: true, domain: 'test.com' })).rejects.toThrowError('server-messages.errors.app-not-exposable');
   });
 
   it('Should throw if app is exposed and domain is not valid', async () => {
@@ -92,7 +92,7 @@ describe('Install app', () => {
     const appConfig = createAppConfig({ exposable: true });
 
     // act & assert
-    await expect(AppsService.installApp(appConfig.id, {}, true, 'test')).rejects.toThrowError('server-messages.errors.domain-not-valid');
+    await expect(AppsService.installApp(appConfig.id, { exposed: true, domain: 'test' })).rejects.toThrowError('server-messages.errors.domain-not-valid');
   });
 
   it('Should throw if app is exposed and domain is already used by another exposed app', async () => {
@@ -103,7 +103,7 @@ describe('Install app', () => {
     await insertApp({ domain, exposed: true }, appConfig2, db);
 
     // act & assert
-    await expect(AppsService.installApp(appConfig.id, {}, true, domain)).rejects.toThrowError('server-messages.errors.domain-already-in-use');
+    await expect(AppsService.installApp(appConfig.id, { exposed: true, domain })).rejects.toThrowError('server-messages.errors.domain-already-in-use');
   });
 
   it('Should throw if architecure is not supported', async () => {
@@ -308,7 +308,7 @@ describe('Update app config', () => {
     await insertApp({}, appConfig, db);
 
     // act & assert
-    expect(AppsService.updateAppConfig(appConfig.id, {}, true)).rejects.toThrowError('server-messages.errors.domain-required-if-expose-app');
+    expect(AppsService.updateAppConfig(appConfig.id, { exposed: true })).rejects.toThrowError('server-messages.errors.domain-required-if-expose-app');
   });
 
   it('Should throw if app is exposed and domain is not valid', async () => {
@@ -317,7 +317,7 @@ describe('Update app config', () => {
     await insertApp({}, appConfig, db);
 
     // act & assert
-    expect(AppsService.updateAppConfig(appConfig.id, {}, true, 'test')).rejects.toThrowError('server-messages.errors.domain-not-valid');
+    expect(AppsService.updateAppConfig(appConfig.id, { exposed: true, domain: 'test' })).rejects.toThrowError('server-messages.errors.domain-not-valid');
   });
 
   it('Should throw if app is exposed and domain is already used', async () => {
@@ -329,7 +329,7 @@ describe('Update app config', () => {
     await insertApp({}, appConfig2, db);
 
     // act & assert
-    await expect(AppsService.updateAppConfig(appConfig2.id, {}, true, domain)).rejects.toThrowError('server-messages.errors.domain-already-in-use');
+    await expect(AppsService.updateAppConfig(appConfig2.id, { exposed: true, domain })).rejects.toThrowError('server-messages.errors.domain-already-in-use');
   });
 
   it('should throw if app is not exposed and config has force_expose set to true', async () => {
@@ -347,7 +347,7 @@ describe('Update app config', () => {
     await insertApp({}, appConfig, db);
 
     // act & assert
-    await expect(AppsService.updateAppConfig(appConfig.id, {}, true, 'test.com')).rejects.toThrowError('server-messages.errors.app-not-exposable');
+    await expect(AppsService.updateAppConfig(appConfig.id, { exposed: true, domain: 'test.com' })).rejects.toThrowError('server-messages.errors.app-not-exposable');
   });
 });
 

+ 37 - 8
src/server/services/apps/apps.service.ts

@@ -11,6 +11,12 @@ import { getConfig } from '../../core/TipiConfig';
 import { Logger } from '../../core/Logger';
 import { notEmpty } from '../../common/typescript.helpers';
 
+type AlwaysFields = {
+  isVisibleOnGuestDashboard?: boolean;
+  domain?: string;
+  exposed?: boolean;
+};
+
 const sortApps = (a: AppInfo, b: AppInfo) => a.id.localeCompare(b.id);
 const filterApp = (app: AppInfo): boolean => {
   if (!app.supported_architectures) {
@@ -101,12 +107,12 @@ export class AppServiceClass {
    *
    * @param {string} id - The id of the app to be installed
    * @param {Record<string, string>} form - The form data submitted by the user
-   * @param {boolean} [exposed] - A flag indicating if the app will be exposed to the internet
-   * @param {string} [domain] - The domain name to expose the app to the internet, required if exposed is true
    */
-  public installApp = async (id: string, form: Record<string, string>, exposed?: boolean, domain?: string) => {
+  public installApp = async (id: string, form: Record<string, unknown> & AlwaysFields) => {
     const app = await this.queries.getApp(id);
 
+    const { exposed, domain, isVisibleOnGuestDashboard } = form;
+
     if (app) {
       await this.startApp(id);
     } else {
@@ -148,7 +154,15 @@ export class AppServiceClass {
         }
       }
 
-      await this.queries.createApp({ id, status: 'installing', config: form, version: appInfo.tipi_version, exposed: exposed || false, domain: domain || null });
+      await this.queries.createApp({
+        id,
+        status: 'installing',
+        config: form,
+        version: appInfo.tipi_version,
+        exposed: exposed || false,
+        domain: domain || null,
+        isVisibleOnGuestDashboard,
+      });
 
       // Run script
       const eventDispatcher = new EventDispatcher('installApp');
@@ -181,10 +195,10 @@ export class AppServiceClass {
    *
    * @param {string} id - The ID of the app to update.
    * @param {object} form - The new configuration of the app.
-   * @param {boolean} [exposed] - If the app should be exposed or not.
-   * @param {string} [domain] - The domain for the app if exposed is true.
    */
-  public updateAppConfig = async (id: string, form: Record<string, string>, exposed?: boolean, domain?: string) => {
+  public updateAppConfig = async (id: string, form: Record<string, unknown> & AlwaysFields) => {
+    const { exposed, domain } = form;
+
     if (exposed && !domain) {
       throw new TranslatedError('server-messages.errors.domain-required-if-expose-app');
     }
@@ -226,7 +240,7 @@ export class AppServiceClass {
     await eventDispatcher.close();
 
     if (success) {
-      const updatedApp = await this.queries.updateApp(id, { exposed: exposed || false, domain: domain || null, config: form });
+      const updatedApp = await this.queries.updateApp(id, { exposed: exposed || false, domain: domain || null, config: form, isVisibleOnGuestDashboard: form.isVisibleOnGuestDashboard });
       return updatedApp;
     }
 
@@ -369,6 +383,21 @@ export class AppServiceClass {
       })
       .filter(notEmpty);
   };
+
+  public getGuestDashboardApps = async () => {
+    const apps = await this.queries.getGuestDashboardApps();
+
+    console.log(apps);
+    return apps
+      .map((app) => {
+        const info = getAppInfo(app.id, app.status);
+        if (info) {
+          return { ...app, info };
+        }
+        return null;
+      })
+      .filter(notEmpty);
+  };
 }
 
 export type AppService = InstanceType<typeof AppServiceClass>;