feat(apps): add "enable on guest dashboard option"
This commit is contained in:
parent
fd6c5afe2c
commit
5830d16382
14 changed files with 79 additions and 40 deletions
1
next-env.d.ts
vendored
1
next-env.d.ts
vendored
|
@ -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.
|
||||
|
|
|
@ -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} />
|
||||
|
|
|
@ -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')}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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', () => {
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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}`);
|
||||
|
|
|
@ -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}`);
|
||||
|
|
|
@ -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
src/lib/helpers/text-helpers.ts
Normal file
1
src/lib/helpers/text-helpers.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export const limitText = (text: string, limit: number) => (text.length > limit ? `${text.substring(0, limit)}...` : text);
|
|
@ -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'>;
|
||||
|
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
@ -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>;
|
||||
|
|
Loading…
Reference in a new issue