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

This commit is contained in:
Nicolas Meienberger 2023-11-02 07:57:13 +01:00 committed by Nicolas Meienberger
parent fd6c5afe2c
commit 5830d16382
14 changed files with 79 additions and 40 deletions

1
next-env.d.ts vendored
View file

@ -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.

View file

@ -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} />

View file

@ -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')}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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."
}

View file

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

View file

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

View file

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

View file

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