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 (
+
+ );
+}
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}
+ />
+
+
+
+
+
+
+ );
+}
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,
+ });
+}