mirror of https://github.com/portainer/portainer
refactor(settings): migrate ssl panel to react [EE-5506] (#9163)
parent
c752b98120
commit
60ae6a63fc
|
@ -10,6 +10,7 @@ import { ApplicationSettingsPanel } from '@/react/portainer/settings/SettingsVie
|
||||||
import { KubeSettingsPanel } from '@/react/portainer/settings/SettingsView/KubeSettingsPanel';
|
import { KubeSettingsPanel } from '@/react/portainer/settings/SettingsView/KubeSettingsPanel';
|
||||||
import { HelmCertPanel } from '@/react/portainer/settings/SettingsView/HelmCertPanel';
|
import { HelmCertPanel } from '@/react/portainer/settings/SettingsView/HelmCertPanel';
|
||||||
import { HiddenContainersPanel } from '@/react/portainer/settings/SettingsView/HiddenContainersPanel/HiddenContainersPanel';
|
import { HiddenContainersPanel } from '@/react/portainer/settings/SettingsView/HiddenContainersPanel/HiddenContainersPanel';
|
||||||
|
import { SSLSettingsPanelWrapper } from '@/react/portainer/settings/SettingsView/SSLSettingsPanel/SSLSettingsPanel';
|
||||||
|
|
||||||
export const settingsModule = angular
|
export const settingsModule = angular
|
||||||
.module('portainer.app.react.components.settings', [])
|
.module('portainer.app.react.components.settings', [])
|
||||||
|
@ -26,6 +27,10 @@ export const settingsModule = angular
|
||||||
'applicationSettingsPanel',
|
'applicationSettingsPanel',
|
||||||
r2a(withReactQuery(ApplicationSettingsPanel), ['onSuccess'])
|
r2a(withReactQuery(ApplicationSettingsPanel), ['onSuccess'])
|
||||||
)
|
)
|
||||||
|
.component(
|
||||||
|
'sslSettingsPanel',
|
||||||
|
r2a(withReactQuery(SSLSettingsPanelWrapper), [])
|
||||||
|
)
|
||||||
.component('helmCertPanel', r2a(withReactQuery(HelmCertPanel), []))
|
.component('helmCertPanel', r2a(withReactQuery(HelmCertPanel), []))
|
||||||
.component(
|
.component(
|
||||||
'hiddenContainersPanel',
|
'hiddenContainersPanel',
|
||||||
|
|
|
@ -1,5 +1,3 @@
|
||||||
import angular from 'angular';
|
import angular from 'angular';
|
||||||
|
|
||||||
import { sslCertificate } from './ssl-certificate';
|
export default angular.module('portainer.settings.general', []).name;
|
||||||
|
|
||||||
export default angular.module('portainer.settings.general', []).component('sslCertificateSettings', sslCertificate).name;
|
|
||||||
|
|
|
@ -1,6 +0,0 @@
|
||||||
import controller from './ssl-certificate.controller.js';
|
|
||||||
|
|
||||||
export const sslCertificate = {
|
|
||||||
templateUrl: './ssl-certificate.html',
|
|
||||||
controller,
|
|
||||||
};
|
|
|
@ -1,78 +0,0 @@
|
||||||
class SslCertificateController {
|
|
||||||
/* @ngInject */
|
|
||||||
constructor($async, $scope, $state, SSLService, Notifications) {
|
|
||||||
Object.assign(this, { $async, $scope, $state, SSLService, Notifications });
|
|
||||||
|
|
||||||
this.cert = null;
|
|
||||||
this.originalValues = {
|
|
||||||
forceHTTPS: false,
|
|
||||||
certFile: null,
|
|
||||||
keyFile: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
this.formValues = {
|
|
||||||
certFile: null,
|
|
||||||
keyFile: null,
|
|
||||||
forceHTTPS: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
this.state = {
|
|
||||||
actionInProgress: false,
|
|
||||||
reloadingPage: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
this.certFilePattern = `.pem,.crt,.cer,.cert`;
|
|
||||||
this.keyFilePattern = `.pem,.key`;
|
|
||||||
|
|
||||||
this.save = this.save.bind(this);
|
|
||||||
this.onChangeForceHTTPS = this.onChangeForceHTTPS.bind(this);
|
|
||||||
}
|
|
||||||
|
|
||||||
isFormChanged() {
|
|
||||||
return Object.entries(this.originalValues).some(([key, value]) => value != this.formValues[key]);
|
|
||||||
}
|
|
||||||
|
|
||||||
onChangeForceHTTPS(checked) {
|
|
||||||
return this.$scope.$evalAsync(() => {
|
|
||||||
this.formValues.forceHTTPS = checked;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async save() {
|
|
||||||
return this.$async(async () => {
|
|
||||||
this.state.actionInProgress = true;
|
|
||||||
try {
|
|
||||||
const cert = this.formValues.certFile ? await this.formValues.certFile.text() : null;
|
|
||||||
const key = this.formValues.keyFile ? await this.formValues.keyFile.text() : null;
|
|
||||||
const httpEnabled = !this.formValues.forceHTTPS;
|
|
||||||
await this.SSLService.upload({ httpEnabled, cert, key });
|
|
||||||
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
|
||||||
location.reload();
|
|
||||||
this.state.reloadingPage = true;
|
|
||||||
} catch (err) {
|
|
||||||
this.Notifications.error('Failure', err, 'Failed applying changes');
|
|
||||||
}
|
|
||||||
this.state.actionInProgress = false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
wasHTTPsChanged() {
|
|
||||||
return this.originalValues.forceHTTPS !== this.formValues.forceHTTPS;
|
|
||||||
}
|
|
||||||
|
|
||||||
async $onInit() {
|
|
||||||
return this.$async(async () => {
|
|
||||||
try {
|
|
||||||
const certInfo = await this.SSLService.get();
|
|
||||||
|
|
||||||
this.formValues.forceHTTPS = !certInfo.httpEnabled;
|
|
||||||
this.originalValues.forceHTTPS = this.formValues.forceHTTPS;
|
|
||||||
} catch (err) {
|
|
||||||
this.Notifications.error('Failure', err, 'Failed loading certificate info');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default SslCertificateController;
|
|
|
@ -1,121 +0,0 @@
|
||||||
<div class="row">
|
|
||||||
<div class="col-sm-12">
|
|
||||||
<rd-widget>
|
|
||||||
<rd-widget-header icon="key" title-text="SSL certificate"></rd-widget-header>
|
|
||||||
<rd-widget-body>
|
|
||||||
<form class="form-horizontal" name="$ctrl.sslForm">
|
|
||||||
<span class="small">
|
|
||||||
<p class="text-muted vertical-center">
|
|
||||||
<pr-icon icon="'alert-circle'" class-name="'icon-warning =vertical-center'"></pr-icon>
|
|
||||||
Forcing HTTPs only will cause Portainer to stop listening on the HTTP port. Any edge agent environment that is using HTTP will no longer be available.
|
|
||||||
</p>
|
|
||||||
</span>
|
|
||||||
<div class="form-group">
|
|
||||||
<por-switch-field
|
|
||||||
checked="$ctrl.formValues.forceHTTPS"
|
|
||||||
label="'Force HTTPS only'"
|
|
||||||
on-change="($ctrl.onChangeForceHTTPS)"
|
|
||||||
field-class="'col-sm-12'"
|
|
||||||
label-class="'col-sm-3 col-lg-2'"
|
|
||||||
></por-switch-field>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<span class="small text-muted vertical-center my-3">
|
|
||||||
<pr-icon icon="'info'" mode="'primary'"></pr-icon>
|
|
||||||
Provide a new SSL Certificate to replace the existing one that is used for HTTPS connections.
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<!-- SSL Cert -->
|
|
||||||
<div class="form-group">
|
|
||||||
<div class="col-sm-12 flex items-center">
|
|
||||||
<span class="space-right control-label col-sm-3 col-lg-2 !p-0 text-left">
|
|
||||||
SSL/TLS certificate
|
|
||||||
<portainer-tooltip message="'Select an X.509 certificate file, commonly a crt, cer or pem file.'"></portainer-tooltip>
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
class="btn btn-sm btn-primary !ml-0"
|
|
||||||
ngf-select
|
|
||||||
ng-model="$ctrl.formValues.certFile"
|
|
||||||
ngf-pattern="$ctrl.certFilePattern"
|
|
||||||
name="certFile"
|
|
||||||
ngf-accept="$ctrl.certFilePattern"
|
|
||||||
>
|
|
||||||
Select a file
|
|
||||||
</button>
|
|
||||||
<span class="ml-1 flex h-full items-center">
|
|
||||||
{{ $ctrl.formValues.certFile.name }}
|
|
||||||
<pr-icon icon="'x-circle'" class-name="'icon-danger'" ng-if="!$ctrl.formValues.certFile"></pr-icon>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-group col-md-12" ng-show="$ctrl.sslForm.certFile.$invalid">
|
|
||||||
<div class="small text-warning">
|
|
||||||
<div ng-messages="$ctrl.sslForm.certFile.$error">
|
|
||||||
<p ng-message="pattern">
|
|
||||||
<pr-icon icon="'alert-triangle'" mode="'warning'"></pr-icon>
|
|
||||||
File type is invalid.</p
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- SSL Key -->
|
|
||||||
<div class="form-group">
|
|
||||||
<div class="col-sm-12 flex items-center">
|
|
||||||
<span class="space-right control-label col-sm-3 col-lg-2 !p-0 text-left">
|
|
||||||
SSL/TLS private key
|
|
||||||
<portainer-tooltip message="'Select a private key file, commonly a key, or pem file.'"></portainer-tooltip>
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
class="btn btn-sm btn-primary !ml-0"
|
|
||||||
ngf-select
|
|
||||||
ng-model="$ctrl.formValues.keyFile"
|
|
||||||
ngf-pattern="$ctrl.keyFilePattern"
|
|
||||||
name="keyFile"
|
|
||||||
ngf-accept="$ctrl.certFilePattern"
|
|
||||||
>
|
|
||||||
Select a file
|
|
||||||
</button>
|
|
||||||
<span class="ml-1 flex h-full items-center">
|
|
||||||
{{ $ctrl.formValues.keyFile.name }}
|
|
||||||
<pr-icon icon="'x-circle'" class-name="'icon-danger'" ng-if="!$ctrl.formValues.keyFile"></pr-icon>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-group col-md-12" ng-show="$ctrl.sslForm.keyFile.$invalid">
|
|
||||||
<div class="small text-warning">
|
|
||||||
<div ng-messages="$ctrl.sslForm.keyFile.$error">
|
|
||||||
<p ng-message="pattern">
|
|
||||||
<pr-icon icon="'alert-triangle'" mode="'warning'"></pr-icon>
|
|
||||||
File type is invalid.</p
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<div class="col-sm-12">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn btn-primary btn-sm !ml-0"
|
|
||||||
ng-disabled="$ctrl.state.actionInProgress || !$ctrl.isFormChanged()"
|
|
||||||
ng-click="$ctrl.save()"
|
|
||||||
button-spinner="$ctrl.state.actionInProgress"
|
|
||||||
analytics-on
|
|
||||||
analytics-if="$ctrl.wasHTTPsChanged()"
|
|
||||||
analytics-category="portainer"
|
|
||||||
analytics-event="portainer-settings-edit"
|
|
||||||
analytics-properties="{ metadata: { forceHTTPS: $ctrl.formValues.forceHTTPS } }"
|
|
||||||
>
|
|
||||||
<span ng-hide="$ctrl.state.actionInProgress || $ctrl.state.reloadingPage">Apply changes</span>
|
|
||||||
<span ng-show="$ctrl.state.actionInProgress">Saving in progress...</span>
|
|
||||||
<span ng-show="$ctrl.state.reloadingPage">Reloading Page...</span>
|
|
||||||
</button>
|
|
||||||
<span class="text-danger" ng-if="state.formValidationError" style="margin-left: 5px">{{ state.formValidationError }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</rd-widget-body>
|
|
||||||
</rd-widget>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
|
@ -6,7 +6,7 @@
|
||||||
|
|
||||||
<helm-cert-panel></helm-cert-panel>
|
<helm-cert-panel></helm-cert-panel>
|
||||||
|
|
||||||
<ssl-certificate-settings ng-show="state.showHTTPS"></ssl-certificate-settings>
|
<ssl-settings-panel></ssl-settings-panel>
|
||||||
|
|
||||||
<hidden-containers-panel></hidden-containers-panel>
|
<hidden-containers-panel></hidden-containers-panel>
|
||||||
|
|
||||||
|
|
|
@ -30,7 +30,7 @@ export function FileUploadField({
|
||||||
const fileRef = createRef<HTMLInputElement>();
|
const fileRef = createRef<HTMLInputElement>();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="file-upload-field vertical-center">
|
<div className="file-upload-field flex gap-2">
|
||||||
<input
|
<input
|
||||||
id={inputId}
|
id={inputId}
|
||||||
ref={fileRef}
|
ref={fileRef}
|
||||||
|
|
|
@ -20,6 +20,7 @@ export function FormActions({
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<div className="col-sm-12">
|
<div className="col-sm-12">
|
||||||
<LoadingButton
|
<LoadingButton
|
||||||
|
className="!ml-0"
|
||||||
loadingText={loadingText}
|
loadingText={loadingText}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
disabled={!isValid}
|
disabled={!isValid}
|
||||||
|
|
|
@ -43,6 +43,7 @@ export function HelmCertPanel() {
|
||||||
initialValues={initialValues}
|
initialValues={initialValues}
|
||||||
validationSchema={validation}
|
validationSchema={validation}
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
|
validateOnMount
|
||||||
>
|
>
|
||||||
<InnerForm isLoading={mutation.isLoading} />
|
<InnerForm isLoading={mutation.isLoading} />
|
||||||
</Formik>
|
</Formik>
|
||||||
|
@ -116,6 +117,6 @@ function validation(): SchemaOf<FormValues> {
|
||||||
'crt',
|
'crt',
|
||||||
'cer',
|
'cer',
|
||||||
'cert',
|
'cert',
|
||||||
]).required(),
|
]).required(''),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,169 @@
|
||||||
|
import { Form, Formik } from 'formik';
|
||||||
|
import { Key } from 'lucide-react';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { SchemaOf, bool, object } from 'yup';
|
||||||
|
|
||||||
|
import { withHideOnExtension } from '@/react/hooks/withHideOnExtension';
|
||||||
|
|
||||||
|
import { Widget } from '@@/Widget';
|
||||||
|
import { LoadingButton } from '@@/buttons';
|
||||||
|
import {
|
||||||
|
file,
|
||||||
|
withFileExtension,
|
||||||
|
} from '@@/form-components/yup-file-validation';
|
||||||
|
import { TextTip } from '@@/Tip/TextTip';
|
||||||
|
import { FormControl } from '@@/form-components/FormControl';
|
||||||
|
import { FileUploadField } from '@@/form-components/FileUpload';
|
||||||
|
import { SwitchField } from '@@/form-components/SwitchField';
|
||||||
|
|
||||||
|
import { useUpdateSSLConfigMutation } from '../useUpdateSSLConfigMutation';
|
||||||
|
import { useSSLSettings } from '../../queries/useSSLSettings';
|
||||||
|
|
||||||
|
interface FormValues {
|
||||||
|
certFile: File | null;
|
||||||
|
keyFile: File | null;
|
||||||
|
forceHTTPS: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SSLSettingsPanelWrapper = withHideOnExtension(SSLSettingsPanel);
|
||||||
|
|
||||||
|
function SSLSettingsPanel() {
|
||||||
|
const settingsQuery = useSSLSettings();
|
||||||
|
const [reloadingPage, setReloadingPage] = useState(false);
|
||||||
|
const mutation = useUpdateSSLConfigMutation();
|
||||||
|
|
||||||
|
if (!settingsQuery.data) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialValues: FormValues = {
|
||||||
|
certFile: null,
|
||||||
|
keyFile: null,
|
||||||
|
forceHTTPS: !settingsQuery.data.httpEnabled,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="row">
|
||||||
|
<div className="col-sm-12">
|
||||||
|
<Widget>
|
||||||
|
<Widget.Title icon={Key} title="SSL certificate" />
|
||||||
|
<Widget.Body>
|
||||||
|
<Formik
|
||||||
|
initialValues={initialValues}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
validationSchema={validation}
|
||||||
|
validateOnMount
|
||||||
|
>
|
||||||
|
{({ values, setFieldValue, isValid, errors }) => (
|
||||||
|
<Form className="form-horizontal">
|
||||||
|
<div className="form-group">
|
||||||
|
<div className="col-sm-12">
|
||||||
|
<TextTip color="orange">
|
||||||
|
Forcing HTTPs only will cause Portainer to stop
|
||||||
|
listening on the HTTP port. Any edge agent environment
|
||||||
|
that is using HTTP will no longer be available.
|
||||||
|
</TextTip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<div className="col-sm-12">
|
||||||
|
<SwitchField
|
||||||
|
checked={values.forceHTTPS}
|
||||||
|
label="Force HTTPS only"
|
||||||
|
labelClass="col-sm-3 col-lg-2"
|
||||||
|
name="forceHTTPS"
|
||||||
|
onChange={(value) => setFieldValue('forceHTTPS', value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<div className="col-sm-12">
|
||||||
|
<TextTip color="blue">
|
||||||
|
Provide a new SSL Certificate to replace the existing
|
||||||
|
one that is used for HTTPS connections.
|
||||||
|
</TextTip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FormControl
|
||||||
|
label="SSL/TLS certificate"
|
||||||
|
tooltip="Select an X.509 certificate file, commonly a crt, cer or pem file."
|
||||||
|
inputId="ca-cert-field"
|
||||||
|
errors={errors.certFile}
|
||||||
|
>
|
||||||
|
<FileUploadField
|
||||||
|
required={typeof errors.certFile !== 'undefined'}
|
||||||
|
inputId="ca-cert-field"
|
||||||
|
name="certFile"
|
||||||
|
onChange={(file) => setFieldValue('certFile', file)}
|
||||||
|
value={values.certFile}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<FormControl
|
||||||
|
label="SSL/TLS private key"
|
||||||
|
tooltip="Select a private key file, commonly a key, or pem file."
|
||||||
|
inputId="ca-cert-field"
|
||||||
|
errors={errors.keyFile}
|
||||||
|
>
|
||||||
|
<FileUploadField
|
||||||
|
required={typeof errors.keyFile !== 'undefined'}
|
||||||
|
inputId="ca-cert-field"
|
||||||
|
name="keyFile"
|
||||||
|
onChange={(file) => setFieldValue('keyFile', file)}
|
||||||
|
value={values.keyFile}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<div className="col-sm-12">
|
||||||
|
<LoadingButton
|
||||||
|
isLoading={mutation.isLoading || reloadingPage}
|
||||||
|
disabled={!isValid}
|
||||||
|
loadingText={reloadingPage ? 'Reloading' : 'Saving'}
|
||||||
|
className="!ml-0"
|
||||||
|
>
|
||||||
|
Save SSL Settings
|
||||||
|
</LoadingButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
)}
|
||||||
|
</Formik>
|
||||||
|
</Widget.Body>
|
||||||
|
</Widget>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
function handleSubmit({ certFile, forceHTTPS, keyFile }: FormValues) {
|
||||||
|
if (!certFile || !keyFile) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
mutation.mutate(
|
||||||
|
{ certFile, httpEnabled: !forceHTTPS, keyFile },
|
||||||
|
{
|
||||||
|
async onSuccess() {
|
||||||
|
await new Promise((resolve) => {
|
||||||
|
setTimeout(resolve, 5000);
|
||||||
|
});
|
||||||
|
window.location.reload();
|
||||||
|
setReloadingPage(true);
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validation(): SchemaOf<FormValues> {
|
||||||
|
return object({
|
||||||
|
certFile: withFileExtension(file(), ['pem', 'crt', 'cer', 'cert']).required(
|
||||||
|
''
|
||||||
|
),
|
||||||
|
keyFile: withFileExtension(file(), ['pem', 'key']).required(''),
|
||||||
|
forceHTTPS: bool().required(),
|
||||||
|
});
|
||||||
|
}
|
|
@ -0,0 +1,24 @@
|
||||||
|
import { useQuery } from 'react-query';
|
||||||
|
|
||||||
|
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||||
|
|
||||||
|
interface SSLSettings {
|
||||||
|
certPath: string;
|
||||||
|
keyPath: string;
|
||||||
|
caCertPath: string;
|
||||||
|
selfSigned: boolean;
|
||||||
|
httpEnabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSSLSettings() {
|
||||||
|
return useQuery<SSLSettings>(['sslSettings'], async () => getSSLSettings());
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getSSLSettings() {
|
||||||
|
try {
|
||||||
|
const response = await axios.get<SSLSettings>('/ssl');
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
throw parseAxiosError(error, 'Unable to retrieve SSL settings');
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue