diff --git a/app/portainer/react/components/settings.ts b/app/portainer/react/components/settings.ts index 2bb90e9d9..16304bbd5 100644 --- a/app/portainer/react/components/settings.ts +++ b/app/portainer/react/components/settings.ts @@ -6,6 +6,7 @@ import { InternalAuth } from '@/react/portainer/settings/AuthenticationView/Inte import { r2a } from '@/react-tools/react2angular'; import { withReactQuery } from '@/react-tools/withReactQuery'; import { withUIRouter } from '@/react-tools/withUIRouter'; +import { ApplicationSettingsPanel } from '@/react/portainer/settings/SettingsView/ApplicationSettingsPanel'; export const settingsModule = angular .module('portainer.app.react.components.settings', []) @@ -17,4 +18,8 @@ export const settingsModule = angular .component( 'internalAuth', r2a(InternalAuth, ['onSaveSettings', 'isLoading', 'value', 'onChange']) + ) + .component( + 'applicationSettingsPanel', + r2a(withReactQuery(ApplicationSettingsPanel), ['onSuccess']) ).name; diff --git a/app/portainer/views/settings/settings.html b/app/portainer/views/settings/settings.html index 9b846fb3f..aaa53ae3e 100644 --- a/app/portainer/views/settings/settings.html +++ b/app/portainer/views/settings/settings.html @@ -1,137 +1,6 @@ -
-
- - - -
- -
- -
- -
-
- - - - - - - -
- -
- You cannot use this feature in the demo version of Portainer. -
-
- -
-
- You can specify the URL to your logo here. For an optimal display, logo dimensions should be 155px by 55px. -
-
- -
- -
-
-
- - -
- -
- You cannot use this feature in the demo version of Portainer. -
-
- You can find more information about this in our - privacy policy. -
-
- -
- -
- - -
App Templates
-
-
- - You can specify the URL to your own template definitions file here. See - Portainer documentation for more details. - -
-
- -
- -
-
-
- - -
-
- -
-
- -
-
-
-
-
+
diff --git a/app/portainer/views/settings/settingsController.js b/app/portainer/views/settings/settingsController.js index 5ad50e716..b749af019 100644 --- a/app/portainer/views/settings/settingsController.js +++ b/app/portainer/views/settings/settingsController.js @@ -11,14 +11,14 @@ angular.module('portainer.app').controller('SettingsController', [ 'BackupService', 'FileSaver', function ($scope, Notifications, SettingsService, StateManager, BackupService, FileSaver) { - $scope.customBannerFeatureId = FeatureId.CUSTOM_LOGIN_BANNER; $scope.s3BackupFeatureId = FeatureId.S3_BACKUP_SETTING; $scope.enforceDeploymentOptions = FeatureId.ENFORCE_DEPLOYMENT_OPTIONS; + $scope.updateSettings = updateSettings; + $scope.handleSuccess = handleSuccess; $scope.backupOptions = options; $scope.state = { - isDemo: false, actionInProgress: false, availableKubeconfigExpiryOptions: [ { @@ -50,32 +50,16 @@ angular.module('portainer.app').controller('SettingsController', [ $scope.BACKUP_FORM_TYPES = { S3: 's3', FILE: 'file' }; $scope.formValues = { - customLogo: false, KubeconfigExpiry: undefined, HelmRepositoryURL: undefined, BlackListedLabels: [], labelName: '', labelValue: '', - enableTelemetry: false, passwordProtect: false, password: '', backupFormType: $scope.BACKUP_FORM_TYPES.FILE, }; - $scope.initialFormValues = {}; - - $scope.onToggleEnableTelemetry = function onToggleEnableTelemetry(checked) { - $scope.$evalAsync(() => { - $scope.formValues.enableTelemetry = checked; - }); - }; - - $scope.onToggleCustomLogo = function onToggleCustomLogo(checked) { - $scope.$evalAsync(() => { - $scope.formValues.customLogo = checked; - }); - }; - $scope.onToggleAutoBackups = function onToggleAutoBackups(checked) { $scope.$evalAsync(() => { $scope.formValues.scheduleAutomaticBackups = checked; @@ -87,13 +71,6 @@ angular.module('portainer.app').controller('SettingsController', [ $scope.state.featureLimited = limited; }; - $scope.onChangeCheckInInterval = function (interval) { - $scope.$evalAsync(() => { - var settings = $scope.settings; - settings.EdgeAgentCheckinInterval = interval; - }); - }; - $scope.removeFilteredContainerLabel = function (index) { const filteredSettings = $scope.formValues.BlackListedLabels.filter((_, i) => i !== index); const filteredSettingsPayload = { BlackListedLabels: filteredSettings }; @@ -133,20 +110,6 @@ angular.module('portainer.app').controller('SettingsController', [ }); }; - // only update the values from the app settings widget. In future separate the api endpoints - $scope.saveApplicationSettings = function () { - const appSettingsPayload = { - SnapshotInterval: $scope.settings.SnapshotInterval, - LogoURL: $scope.formValues.customLogo ? $scope.settings.LogoURL : '', - EnableTelemetry: $scope.formValues.enableTelemetry, - TemplatesURL: $scope.settings.TemplatesURL, - EdgeAgentCheckinInterval: $scope.settings.EdgeAgentCheckinInterval, - }; - - $scope.state.actionInProgress = true; - updateSettings(appSettingsPayload, 'Application settings updated'); - }; - // only update the values from the kube settings widget. In future separate the api endpoints $scope.saveKubernetesSettings = function () { const kubeSettingsPayload = { @@ -160,14 +123,10 @@ angular.module('portainer.app').controller('SettingsController', [ }; function updateSettings(settings, successMessage = 'Settings updated') { - SettingsService.update(settings) - .then(function success(response) { + return SettingsService.update(settings) + .then(function success(settings) { Notifications.success('Success', successMessage); - StateManager.updateLogo(settings.LogoURL); - StateManager.updateSnapshotInterval(settings.SnapshotInterval); - StateManager.updateEnableTelemetry(settings.EnableTelemetry); - $scope.initialFormValues.enableTelemetry = response.EnableTelemetry; - $scope.formValues.BlackListedLabels = response.BlackListedLabels; + handleSuccess(settings); }) .catch(function error(err) { Notifications.error('Failure', err, 'Unable to update settings'); @@ -178,24 +137,28 @@ angular.module('portainer.app').controller('SettingsController', [ }); } - function initView() { - const state = StateManager.getState(); - $scope.state.isDemo = state.application.demoEnvironment.enabled; + function handleSuccess(settings) { + if (settings) { + StateManager.updateLogo(settings.LogoURL); + StateManager.updateSnapshotInterval(settings.SnapshotInterval); + StateManager.updateEnableTelemetry(settings.EnableTelemetry); + $scope.formValues.BlackListedLabels = settings.BlackListedLabels; + } + // trigger an event to update the deployment options for the react based sidebar + const event = new CustomEvent('portainer:deploymentOptionsUpdated'); + document.dispatchEvent(event); + } + + function initView() { SettingsService.settings() .then(function success(data) { var settings = data; $scope.settings = settings; - if (settings.LogoURL !== '') { - $scope.formValues.customLogo = true; - } - - $scope.formValues.enableTelemetry = settings.EnableTelemetry; $scope.formValues.KubeconfigExpiry = settings.KubeconfigExpiry; $scope.formValues.HelmRepositoryURL = settings.HelmRepositoryURL; $scope.formValues.BlackListedLabels = settings.BlackListedLabels; - $scope.initialFormValues.enableTelemetry = settings.EnableTelemetry; }) .catch(function error(err) { Notifications.error('Failure', err, 'Unable to retrieve application settings'); diff --git a/app/react/components/form-components/validate-url.ts b/app/react/components/form-components/validate-url.ts new file mode 100644 index 000000000..bfe766e8a --- /dev/null +++ b/app/react/components/form-components/validate-url.ts @@ -0,0 +1,15 @@ +export function isValidUrl( + value: string | undefined, + additionalCheck: (url: URL) => boolean = () => true +) { + if (!value) { + return false; + } + + try { + const url = new URL(value); + return additionalCheck(url); + } catch { + return false; + } +} diff --git a/app/react/edge/components/EdgeCheckInIntervalField.tsx b/app/react/edge/components/EdgeCheckInIntervalField.tsx index e266af70d..d3cb38a8e 100644 --- a/app/react/edge/components/EdgeCheckInIntervalField.tsx +++ b/app/react/edge/components/EdgeCheckInIntervalField.tsx @@ -61,6 +61,7 @@ export function EdgeCheckinIntervalField({ }} options={options} disabled={readonly} + id="edge_checkin" /> ); diff --git a/app/react/portainer/common/PortainerUrlField.tsx b/app/react/portainer/common/PortainerUrlField.tsx index 826b8fee2..e85b96859 100644 --- a/app/react/portainer/common/PortainerUrlField.tsx +++ b/app/react/portainer/common/PortainerUrlField.tsx @@ -3,6 +3,7 @@ import { string } from 'yup'; import { FormControl } from '@@/form-components/FormControl'; import { Input } from '@@/form-components/Input'; +import { isValidUrl } from '@@/form-components/validate-url'; interface Props { fieldName: string; @@ -47,18 +48,11 @@ export function validation() { .test( 'valid API server URL', 'The API server URL must be a valid URL (localhost cannot be used)', - (value) => { - if (!value) { - return false; - } - - try { - const url = new URL(value); - return !!url.hostname && url.hostname !== 'localhost'; - } catch { - return false; - } - } + (value) => + isValidUrl( + value, + (url) => !!url.hostname && url.hostname !== 'localhost' + ) ); } diff --git a/app/react/portainer/settings/SettingsView/ApplicationSettingsPanel/ApplicationSettingsPanel.tsx b/app/react/portainer/settings/SettingsView/ApplicationSettingsPanel/ApplicationSettingsPanel.tsx new file mode 100644 index 000000000..f0be07503 --- /dev/null +++ b/app/react/portainer/settings/SettingsView/ApplicationSettingsPanel/ApplicationSettingsPanel.tsx @@ -0,0 +1,138 @@ +import { Settings as SettingsIcon } from 'lucide-react'; +import { Field, Form, Formik, useFormikContext } from 'formik'; + +import { EdgeCheckinIntervalField } from '@/react/edge/components/EdgeCheckInIntervalField'; +import { + useSettings, + useUpdateSettingsMutation, +} from '@/react/portainer/settings/queries'; +import { notifySuccess } from '@/portainer/services/notifications'; + +import { Widget } from '@@/Widget'; +import { LoadingButton } from '@@/buttons'; +import { FormControl } from '@@/form-components/FormControl'; +import { Input } from '@@/form-components/Input'; + +import { type Settings } from '../../types'; + +import { validation } from './validation'; +import { Values } from './types'; +import { LogoFieldset } from './LogoFieldset'; +import { ScreenBannerFieldset } from './ScreenBannerFieldset'; +import { TemplatesUrlSection } from './TemplatesUrlSection'; +import { EnableTelemetryField } from './EnableTelemetryField'; + +export function ApplicationSettingsPanel({ + onSuccess, +}: { + onSuccess(settings: Settings): void; +}) { + const settingsQuery = useSettings(); + const mutation = useUpdateSettingsMutation(); + + if (!settingsQuery.data) { + return null; + } + + const settings = settingsQuery.data; + const initialValues: Values = { + edgeAgentCheckinInterval: settings.EdgeAgentCheckinInterval, + enableTelemetry: settings.EnableTelemetry, + loginBannerEnabled: !!settings.CustomLoginBanner, + loginBanner: settings.CustomLoginBanner, + logoEnabled: !!settings.LogoURL, + logo: settings.LogoURL, + snapshotInterval: settings.SnapshotInterval, + templatesUrl: settings.TemplatesURL, + }; + + return ( +
+
+ + + + + + + + +
+
+ ); + + function handleSubmit(values: Values) { + mutation.mutate( + { + SnapshotInterval: values.snapshotInterval, + LogoURL: values.logo, + EnableTelemetry: values.enableTelemetry, + CustomLoginBanner: values.loginBanner, + TemplatesURL: values.templatesUrl, + EdgeAgentCheckinInterval: values.edgeAgentCheckinInterval, + }, + { + onSuccess(settings) { + notifySuccess('Success', 'Application settings updated'); + onSuccess(settings); + }, + } + ); + } +} + +function InnerForm({ isLoading }: { isLoading: boolean }) { + const { values, setFieldValue, isValid, errors } = useFormikContext(); + + return ( +
+ + + + + setFieldValue('edgeAgentCheckinInterval', value)} + /> + + + + + + + + + +
+
+ + Save application settings + +
+
+ + ); +} diff --git a/app/react/portainer/settings/SettingsView/ApplicationSettingsPanel/DemoAlert.tsx b/app/react/portainer/settings/SettingsView/ApplicationSettingsPanel/DemoAlert.tsx new file mode 100644 index 000000000..e108f6e9b --- /dev/null +++ b/app/react/portainer/settings/SettingsView/ApplicationSettingsPanel/DemoAlert.tsx @@ -0,0 +1,16 @@ +import { useIsDemo } from '@/react/portainer/system/useSystemStatus'; + +export function DemoAlert() { + const isDemoQuery = useIsDemo(); + if (!isDemoQuery.data) { + return null; + } + + return ( +
+ + You cannot use this feature in the demo version of Portainer. + +
+ ); +} diff --git a/app/react/portainer/settings/SettingsView/ApplicationSettingsPanel/EnableTelemetryField.tsx b/app/react/portainer/settings/SettingsView/ApplicationSettingsPanel/EnableTelemetryField.tsx new file mode 100644 index 000000000..c1d9d2a11 --- /dev/null +++ b/app/react/portainer/settings/SettingsView/ApplicationSettingsPanel/EnableTelemetryField.tsx @@ -0,0 +1,41 @@ +import { useField } from 'formik'; + +import { useIsDemo } from '@/react/portainer/system/useSystemStatus'; + +import { SwitchField } from '@@/form-components/SwitchField'; + +import { DemoAlert } from './DemoAlert'; + +export function EnableTelemetryField() { + const isDemoQuery = useIsDemo(); + const [{ value }, , { setValue }] = useField('enableTelemetry'); + + return ( +
+
+ setValue(checked)} + disabled={isDemoQuery.data} + /> +
+ + + +
+ You can find more information about this in our{' '} + + privacy policy + + . +
+
+ ); +} diff --git a/app/react/portainer/settings/SettingsView/ApplicationSettingsPanel/LogoFieldset.tsx b/app/react/portainer/settings/SettingsView/ApplicationSettingsPanel/LogoFieldset.tsx new file mode 100644 index 000000000..54efb0a77 --- /dev/null +++ b/app/react/portainer/settings/SettingsView/ApplicationSettingsPanel/LogoFieldset.tsx @@ -0,0 +1,55 @@ +import { useField, Field } from 'formik'; + +import { useIsDemo } from '@/react/portainer/system/useSystemStatus'; + +import { FormControl } from '@@/form-components/FormControl'; +import { Input } from '@@/form-components/Input'; +import { SwitchField } from '@@/form-components/SwitchField'; + +import { useToggledValue } from './useToggledValue'; +import { DemoAlert } from './DemoAlert'; + +export function LogoFieldset() { + const [{ name }, { error }] = useField('logo'); + const isDemoQuery = useIsDemo(); + + const [isEnabled, setIsEnabled] = useToggledValue('logo'); + + return ( + <> +
+
+ setIsEnabled(checked)} + /> +
+ + +
+ + {isEnabled && ( +
+
+ + You can specify the URL to your logo here. For an optimal display, + logo dimensions should be 155px by 55px. + +
+ + + +
+ )} + + ); +} diff --git a/app/react/portainer/settings/SettingsView/ApplicationSettingsPanel/ScreenBannerFieldset.tsx b/app/react/portainer/settings/SettingsView/ApplicationSettingsPanel/ScreenBannerFieldset.tsx new file mode 100644 index 000000000..4abef9399 --- /dev/null +++ b/app/react/portainer/settings/SettingsView/ApplicationSettingsPanel/ScreenBannerFieldset.tsx @@ -0,0 +1,59 @@ +import { useField, Field } from 'formik'; + +import { FeatureId } from '@/react/portainer/feature-flags/enums'; +import { useIsDemo } from '@/react/portainer/system/useSystemStatus'; + +import { FormControl } from '@@/form-components/FormControl'; +import { TextArea } from '@@/form-components/Input/Textarea'; +import { SwitchField } from '@@/form-components/SwitchField'; + +import { useToggledValue } from './useToggledValue'; +import { DemoAlert } from './DemoAlert'; + +export function ScreenBannerFieldset() { + const isDemoQuery = useIsDemo(); + const [{ name }, { error }] = useField('loginBanner'); + const [isEnabled, setIsEnabled] = useToggledValue('loginBanner'); + + return ( + <> +
+
+ setIsEnabled(checked)} + featureId={FeatureId.CUSTOM_LOGIN_BANNER} + /> +
+ + + +
+ You can set a custom banner that will be shown to all users during + login. +
+
+ + {isEnabled && ( + + + + )} + + ); +} diff --git a/app/react/portainer/settings/SettingsView/ApplicationSettingsPanel/TemplatesUrlSection.tsx b/app/react/portainer/settings/SettingsView/ApplicationSettingsPanel/TemplatesUrlSection.tsx new file mode 100644 index 000000000..6dc77da12 --- /dev/null +++ b/app/react/portainer/settings/SettingsView/ApplicationSettingsPanel/TemplatesUrlSection.tsx @@ -0,0 +1,38 @@ +import { useField, Field } from 'formik'; + +import { FormControl } from '@@/form-components/FormControl'; +import { FormSection } from '@@/form-components/FormSection'; +import { Input } from '@@/form-components/Input'; + +export function TemplatesUrlSection() { + const [{ name }, { error }] = useField('templatesUrl'); + return ( + +
+ + You can specify the URL to your own template definitions file here. + See{' '} + + Portainer documentation + {' '} + for more details. + +
+ + + + +
+ ); +} diff --git a/app/react/portainer/settings/SettingsView/ApplicationSettingsPanel/index.ts b/app/react/portainer/settings/SettingsView/ApplicationSettingsPanel/index.ts new file mode 100644 index 000000000..c7bd41c5c --- /dev/null +++ b/app/react/portainer/settings/SettingsView/ApplicationSettingsPanel/index.ts @@ -0,0 +1 @@ +export { ApplicationSettingsPanel } from './ApplicationSettingsPanel'; diff --git a/app/react/portainer/settings/SettingsView/ApplicationSettingsPanel/types.ts b/app/react/portainer/settings/SettingsView/ApplicationSettingsPanel/types.ts new file mode 100644 index 000000000..8e49fc98c --- /dev/null +++ b/app/react/portainer/settings/SettingsView/ApplicationSettingsPanel/types.ts @@ -0,0 +1,10 @@ +export interface Values { + snapshotInterval: string; + edgeAgentCheckinInterval: number; + enableTelemetry: boolean; + loginBanner: string; + loginBannerEnabled: boolean; + logo: string; + logoEnabled: boolean; + templatesUrl: string; +} diff --git a/app/react/portainer/settings/SettingsView/ApplicationSettingsPanel/useToggledValue.tsx b/app/react/portainer/settings/SettingsView/ApplicationSettingsPanel/useToggledValue.tsx new file mode 100644 index 000000000..0da74e1d0 --- /dev/null +++ b/app/react/portainer/settings/SettingsView/ApplicationSettingsPanel/useToggledValue.tsx @@ -0,0 +1,18 @@ +import { useField } from 'formik'; +import { useState } from 'react'; + +export function useToggledValue(fieldName: string) { + const [, { value }, { setValue }] = useField(fieldName); + const [, { value: isEnabled }, { setValue: setIsEnabled }] = + useField(`${fieldName}Enabled`); + const [oldValue, setOldValue] = useState(value); + + async function handleIsEnabledChange(enabled: boolean) { + setOldValue(enabled ? '' : value); + // `setValue` is async, formik types are wrong for this version + await setIsEnabled(enabled); + await setValue(enabled ? oldValue : '', true); + } + + return [isEnabled, handleIsEnabledChange] as const; +} diff --git a/app/react/portainer/settings/SettingsView/ApplicationSettingsPanel/validation.ts b/app/react/portainer/settings/SettingsView/ApplicationSettingsPanel/validation.ts new file mode 100644 index 000000000..0c9225980 --- /dev/null +++ b/app/react/portainer/settings/SettingsView/ApplicationSettingsPanel/validation.ts @@ -0,0 +1,36 @@ +import { SchemaOf, bool, boolean, number, object, string } from 'yup'; + +import { isValidUrl } from '@@/form-components/validate-url'; + +import { Values } from './types'; + +export function validation(): SchemaOf { + return object({ + edgeAgentCheckinInterval: number().required(), + enableTelemetry: bool().required(), + loginBannerEnabled: boolean().default(false), + loginBanner: string() + .default('') + .when('loginBannerEnabled', { + is: true, + then: (schema) => + schema.required('Login banner is required when enabled'), + }), + logoEnabled: boolean().default(false), + logo: string() + .default('') + .when('logoEnabled', { + is: true, + then: (schema) => + schema + .required('Logo url is required when enabled') + .test('valid-url', 'Must be a valid URL', (value) => + isValidUrl(value) + ), + }), + snapshotInterval: string().required('Snapshot interval is required'), + templatesUrl: string() + .required('Templates URL is required') + .test('valid-url', 'Must be a valid URL', (value) => isValidUrl(value)), + }); +} diff --git a/app/react/portainer/settings/settings.service.ts b/app/react/portainer/settings/settings.service.ts index 0a0c8298f..4255ec173 100644 --- a/app/react/portainer/settings/settings.service.ts +++ b/app/react/portainer/settings/settings.service.ts @@ -34,7 +34,8 @@ type OptionalSettings = Omit, 'Edge'> & { export async function updateSettings(settings: OptionalSettings) { try { - await axios.put(buildUrl(), settings); + const { data } = await axios.put(buildUrl(), settings); + return data; } catch (e) { throw parseAxiosError(e as Error, 'Unable to update application settings'); } diff --git a/app/react/portainer/settings/types.ts b/app/react/portainer/settings/types.ts index 5e77f990f..d9d9b8115 100644 --- a/app/react/portainer/settings/types.ts +++ b/app/react/portainer/settings/types.ts @@ -95,6 +95,7 @@ export interface DefaultRegistry { export interface Settings { LogoURL: string; + CustomLoginBanner: string; BlackListedLabels: Pair[]; AuthenticationMethod: AuthenticationMethod; InternalAuthSettings: { RequiredPasswordLength: number }; diff --git a/app/react/portainer/system/useSystemStatus.ts b/app/react/portainer/system/useSystemStatus.ts index 3817c4ed3..ecc5394f2 100644 --- a/app/react/portainer/system/useSystemStatus.ts +++ b/app/react/portainer/system/useSystemStatus.ts @@ -2,6 +2,10 @@ import { useQuery } from 'react-query'; import { RetryValue } from 'react-query/types/core/retryer'; import axios, { parseAxiosError } from '@/portainer/services/axios'; +import { UserId } from '@/portainer/users/types'; + +import { isBE } from '../feature-flags/feature-flags.service'; +import { EnvironmentId } from '../environments/types'; import { buildUrl } from './build-url'; import { queryKeys } from './query-keys'; @@ -12,13 +16,18 @@ export interface StatusResponse { Edition: string; Version: string; InstanceID: string; + DemoEnvironment: { + Enabled: boolean; + Users: Array; + Environments: Array; + }; } export async function getSystemStatus() { try { const { data } = await axios.get(buildUrl('status')); - data.Edition = 'Community Edition'; + data.Edition = isBE ? 'Business Edition' : 'Community Edition'; return data; } catch (error) { @@ -45,3 +54,9 @@ export function useSystemStatus({ onSuccess, }); } + +export function useIsDemo() { + return useSystemStatus({ + select: (status) => status.DemoEnvironment.Enabled, + }); +}