mirror of https://github.com/portainer/portainer
refactor(settings): migrate helm cert panel to react [EE-5505] (#9132)
parent
c452de82b7
commit
f293ea41d3
|
@ -10,6 +10,7 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
type sslUpdatePayload struct {
|
type sslUpdatePayload struct {
|
||||||
|
// SSL Certificates
|
||||||
Cert *string
|
Cert *string
|
||||||
Key *string
|
Key *string
|
||||||
HTTPEnabled *bool
|
HTTPEnabled *bool
|
||||||
|
|
|
@ -1,9 +1,9 @@
|
||||||
export default class PortainerError extends Error {
|
export default class PortainerError extends Error {
|
||||||
err?: Error;
|
err?: unknown;
|
||||||
|
|
||||||
isPortainerError = true;
|
isPortainerError = true;
|
||||||
|
|
||||||
constructor(msg: string, err?: Error) {
|
constructor(msg: string, err?: unknown) {
|
||||||
super(msg);
|
super(msg);
|
||||||
this.err = err;
|
this.err = err;
|
||||||
}
|
}
|
||||||
|
|
|
@ -10,4 +10,5 @@ export const fileUploadField = r2a(FileUploadField, [
|
||||||
'accept',
|
'accept',
|
||||||
'inputId',
|
'inputId',
|
||||||
'color',
|
'color',
|
||||||
|
'name',
|
||||||
]);
|
]);
|
||||||
|
|
|
@ -8,6 +8,7 @@ import { withReactQuery } from '@/react-tools/withReactQuery';
|
||||||
import { withUIRouter } from '@/react-tools/withUIRouter';
|
import { withUIRouter } from '@/react-tools/withUIRouter';
|
||||||
import { ApplicationSettingsPanel } from '@/react/portainer/settings/SettingsView/ApplicationSettingsPanel';
|
import { ApplicationSettingsPanel } from '@/react/portainer/settings/SettingsView/ApplicationSettingsPanel';
|
||||||
import { KubeSettingsPanel } from '@/react/portainer/settings/SettingsView/KubeSettingsPanel';
|
import { KubeSettingsPanel } from '@/react/portainer/settings/SettingsView/KubeSettingsPanel';
|
||||||
|
import { HelmCertPanel } from '@/react/portainer/settings/SettingsView/HelmCertPanel';
|
||||||
|
|
||||||
export const settingsModule = angular
|
export const settingsModule = angular
|
||||||
.module('portainer.app.react.components.settings', [])
|
.module('portainer.app.react.components.settings', [])
|
||||||
|
@ -24,6 +25,7 @@ export const settingsModule = angular
|
||||||
'applicationSettingsPanel',
|
'applicationSettingsPanel',
|
||||||
r2a(withReactQuery(ApplicationSettingsPanel), ['onSuccess'])
|
r2a(withReactQuery(ApplicationSettingsPanel), ['onSuccess'])
|
||||||
)
|
)
|
||||||
|
.component('helmCertPanel', r2a(withReactQuery(HelmCertPanel), []))
|
||||||
.component(
|
.component(
|
||||||
'kubeSettingsPanel',
|
'kubeSettingsPanel',
|
||||||
r2a(withUIRouter(withReactQuery(KubeSettingsPanel)), [])
|
r2a(withUIRouter(withReactQuery(KubeSettingsPanel)), [])
|
||||||
|
|
|
@ -57,14 +57,14 @@ axios.interceptors.request.use(agentInterceptor);
|
||||||
* @returns A PortainerError with the parsed error message and details.
|
* @returns A PortainerError with the parsed error message and details.
|
||||||
*/
|
*/
|
||||||
export function parseAxiosError(
|
export function parseAxiosError(
|
||||||
err: Error,
|
err: unknown,
|
||||||
msg = '',
|
msg = '',
|
||||||
parseError = defaultErrorParser
|
parseError = defaultErrorParser
|
||||||
) {
|
) {
|
||||||
let resultErr = err;
|
let resultErr = err;
|
||||||
let resultMsg = msg;
|
let resultMsg = msg;
|
||||||
|
|
||||||
if ('isAxiosError' in err) {
|
if (isAxiosError(err)) {
|
||||||
const { error, details } = parseError(err as AxiosError);
|
const { error, details } = parseError(err as AxiosError);
|
||||||
resultErr = error;
|
resultErr = error;
|
||||||
if (msg && details) {
|
if (msg && details) {
|
||||||
|
|
|
@ -1,6 +1,5 @@
|
||||||
import angular from 'angular';
|
import angular from 'angular';
|
||||||
|
|
||||||
import { sslCertificate } from './ssl-certificate';
|
import { sslCertificate } from './ssl-certificate';
|
||||||
import { sslCaFileSettings } from './ssl-ca-file-settings';
|
|
||||||
|
|
||||||
export default angular.module('portainer.settings.general', []).component('sslCertificateSettings', sslCertificate).component('sslCaFileSettings', sslCaFileSettings).name;
|
export default angular.module('portainer.settings.general', []).component('sslCertificateSettings', sslCertificate).name;
|
||||||
|
|
|
@ -1,6 +0,0 @@
|
||||||
import controller from './ssl-ca-file-settings-controller.js';
|
|
||||||
|
|
||||||
export const sslCaFileSettings = {
|
|
||||||
templateUrl: './ssl-ca-file-settings.html',
|
|
||||||
controller,
|
|
||||||
};
|
|
|
@ -1,9 +0,0 @@
|
||||||
import { FeatureId } from '@/react/portainer/feature-flags/enums';
|
|
||||||
class SslCaFileSettingsController {
|
|
||||||
/* @ngInject */
|
|
||||||
constructor() {
|
|
||||||
this.limitedFeature = FeatureId.CA_FILE;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default SslCaFileSettingsController;
|
|
|
@ -1,43 +0,0 @@
|
||||||
<div class="be-indicator-container limited-be">
|
|
||||||
<div class="overlay">
|
|
||||||
<div class="limited-be-link vertical-center"
|
|
||||||
><be-feature-indicator feature="$ctrl.limitedFeature"></be-feature-indicator
|
|
||||||
><portainer-tooltip message="'This feature is currently limited to Business Edition users only. '"></portainer-tooltip
|
|
||||||
></div>
|
|
||||||
<div class="limited-be-content">
|
|
||||||
<rd-widget>
|
|
||||||
<rd-widget-header icon="key" title-text="Certificate Authority file for Kubernetes Helm repositories"></rd-widget-header>
|
|
||||||
<rd-widget-body>
|
|
||||||
<form class="form-horizontal" name="$ctrl.sslForm">
|
|
||||||
<span class="small text-muted vertical-center mb-3">
|
|
||||||
<pr-icon icon="'info'" mode="'primary'"></pr-icon>
|
|
||||||
Provide an additional CA file containing certificate(s) for HTTPS connections to Helm repositories.
|
|
||||||
</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">
|
|
||||||
CA File
|
|
||||||
<portainer-tooltip message="'Select a CA file containing your X.509 certificate(s), commonly a crt, cer or pem file.'"></portainer-tooltip>
|
|
||||||
</span>
|
|
||||||
<button class="btn btn-sm btn-primary !ml-0"> Select a file </button>
|
|
||||||
<span class="ml-1 flex h-full items-center">
|
|
||||||
<pr-icon icon="'x-circle'" class-name="'icon-danger'"></pr-icon>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<div class="col-sm-12">
|
|
||||||
<button type="button" class="btn btn-primary btn-sm !ml-0">
|
|
||||||
<span>Apply changes</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</rd-widget-body>
|
|
||||||
</rd-widget>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
|
@ -1,6 +1,6 @@
|
||||||
<page-header title="'User Activity'" breadcrumbs="['Activity Logs']" reload="true"> </page-header>
|
<page-header title="'User Activity'" breadcrumbs="['Activity Logs']" reload="true"> </page-header>
|
||||||
|
|
||||||
<div class="be-indicator-container limited-be">
|
<div class="be-indicator-container limited-be mx-4">
|
||||||
<div class="overlay">
|
<div class="overlay">
|
||||||
<div class="limited-be-link vertical-center"
|
<div class="limited-be-link vertical-center"
|
||||||
><be-feature-indicator feature="$ctrl.limitedFeature"></be-feature-indicator
|
><be-feature-indicator feature="$ctrl.limitedFeature"></be-feature-indicator
|
||||||
|
@ -33,7 +33,7 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="be-indicator-container limited-be">
|
<div class="be-indicator-container limited-be mx-4">
|
||||||
<div class="overlay">
|
<div class="overlay">
|
||||||
<div class="limited-be-link vertical-center"
|
<div class="limited-be-link vertical-center"
|
||||||
><be-feature-indicator feature="$ctrl.limitedFeature"></be-feature-indicator
|
><be-feature-indicator feature="$ctrl.limitedFeature"></be-feature-indicator
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
<page-header title="'User Activity'" breadcrumbs="['User authentication activity']" reload="true"> </page-header>
|
<page-header title="'User Activity'" breadcrumbs="['User authentication activity']" reload="true"> </page-header>
|
||||||
|
|
||||||
<div class="be-indicator-container limited-be">
|
<div class="be-indicator-container limited-be mx-4">
|
||||||
<div class="overlay">
|
<div class="overlay">
|
||||||
<div class="limited-be-link vertical-center"
|
<div class="limited-be-link vertical-center"
|
||||||
><be-feature-indicator feature="$ctrl.limitedFeature"></be-feature-indicator
|
><be-feature-indicator feature="$ctrl.limitedFeature"></be-feature-indicator
|
||||||
|
@ -32,7 +32,7 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="be-indicator-container limited-be">
|
<div class="be-indicator-container limited-be mx-4">
|
||||||
<div class="overlay">
|
<div class="overlay">
|
||||||
<div class="limited-be-link vertical-center"
|
<div class="limited-be-link vertical-center"
|
||||||
><be-feature-indicator feature="$ctrl.limitedFeature"></be-feature-indicator
|
><be-feature-indicator feature="$ctrl.limitedFeature"></be-feature-indicator
|
||||||
|
|
|
@ -4,7 +4,8 @@
|
||||||
|
|
||||||
<kube-settings-panel></kube-settings-panel>
|
<kube-settings-panel></kube-settings-panel>
|
||||||
|
|
||||||
<ssl-ca-file-settings></ssl-ca-file-settings>
|
<helm-cert-panel></helm-cert-panel>
|
||||||
|
|
||||||
<ssl-certificate-settings ng-show="state.showHTTPS"></ssl-certificate-settings>
|
<ssl-certificate-settings ng-show="state.showHTTPS"></ssl-certificate-settings>
|
||||||
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
|
|
|
@ -22,5 +22,4 @@
|
||||||
|
|
||||||
.be-indicator-container {
|
.be-indicator-container {
|
||||||
border: solid 1px var(--BE-only);
|
border: solid 1px var(--BE-only);
|
||||||
margin: 15px;
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,31 @@
|
||||||
|
import { FeatureId } from '@/react/portainer/feature-flags/enums';
|
||||||
|
import { isLimitedToBE } from '@/react/portainer/feature-flags/feature-flags.service';
|
||||||
|
|
||||||
|
import { Tooltip } from '@@/Tip/Tooltip';
|
||||||
|
|
||||||
|
import { BEFeatureIndicator } from '.';
|
||||||
|
|
||||||
|
export function BEOverlay({
|
||||||
|
featureId,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
featureId: FeatureId;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
const isLimited = isLimitedToBE(featureId);
|
||||||
|
if (!isLimited) {
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="be-indicator-container limited-be">
|
||||||
|
<div className="overlay">
|
||||||
|
<div className="limited-be-link vertical-center">
|
||||||
|
<BEFeatureIndicator featureId={FeatureId.CA_FILE} />
|
||||||
|
<Tooltip message="This feature is currently limited to Business Edition users only. " />
|
||||||
|
</div>
|
||||||
|
<div className="limited-be-content">{children}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
|
@ -14,6 +14,7 @@ export interface Props {
|
||||||
required?: boolean;
|
required?: boolean;
|
||||||
inputId: string;
|
inputId: string;
|
||||||
color?: ComponentProps<typeof Button>['color'];
|
color?: ComponentProps<typeof Button>['color'];
|
||||||
|
name?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FileUploadField({
|
export function FileUploadField({
|
||||||
|
@ -24,6 +25,7 @@ export function FileUploadField({
|
||||||
required = false,
|
required = false,
|
||||||
inputId,
|
inputId,
|
||||||
color = 'primary',
|
color = 'primary',
|
||||||
|
name,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const fileRef = createRef<HTMLInputElement>();
|
const fileRef = createRef<HTMLInputElement>();
|
||||||
|
|
||||||
|
@ -38,6 +40,7 @@ export function FileUploadField({
|
||||||
className={styles.fileInput}
|
className={styles.fileInput}
|
||||||
onChange={changeHandler}
|
onChange={changeHandler}
|
||||||
aria-label="file-input"
|
aria-label="file-input"
|
||||||
|
name={name}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
size="small"
|
size="small"
|
||||||
|
|
|
@ -0,0 +1,34 @@
|
||||||
|
import { PropsWithChildren } from 'react';
|
||||||
|
|
||||||
|
import { LoadingButton } from '@@/buttons';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
submitLabel: string;
|
||||||
|
loadingText: string;
|
||||||
|
isLoading: boolean;
|
||||||
|
isValid: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FormActions({
|
||||||
|
submitLabel = 'Save',
|
||||||
|
loadingText = 'Saving',
|
||||||
|
isLoading,
|
||||||
|
children,
|
||||||
|
isValid,
|
||||||
|
}: PropsWithChildren<Props>) {
|
||||||
|
return (
|
||||||
|
<div className="form-group">
|
||||||
|
<div className="col-sm-12">
|
||||||
|
<LoadingButton
|
||||||
|
loadingText={loadingText}
|
||||||
|
isLoading={isLoading}
|
||||||
|
disabled={!isValid}
|
||||||
|
>
|
||||||
|
{submitLabel}
|
||||||
|
</LoadingButton>
|
||||||
|
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
|
@ -22,3 +22,24 @@ export function withFileSize(fileValidation: FileSchema, maxSize: number) {
|
||||||
return file.size <= maxSize;
|
return file.size <= maxSize;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function withFileExtension(
|
||||||
|
fileValidation: FileSchema,
|
||||||
|
allowedExtensions: string[]
|
||||||
|
) {
|
||||||
|
return fileValidation.test(
|
||||||
|
'fileExtension',
|
||||||
|
'Selected file has invalid extension.',
|
||||||
|
validateFileExtension
|
||||||
|
);
|
||||||
|
|
||||||
|
function validateFileExtension(file?: File) {
|
||||||
|
if (!file) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileExtension = file.name.split('.').pop();
|
||||||
|
|
||||||
|
return allowedExtensions.includes(fileExtension || '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -0,0 +1,121 @@
|
||||||
|
import { Form, Formik, useFormikContext } from 'formik';
|
||||||
|
import { Key } from 'lucide-react';
|
||||||
|
import { SchemaOf, object } from 'yup';
|
||||||
|
|
||||||
|
import { notifySuccess } from '@/portainer/services/notifications';
|
||||||
|
|
||||||
|
import { Widget } from '@@/Widget';
|
||||||
|
import { TextTip } from '@@/Tip/TextTip';
|
||||||
|
import { FileUploadField } from '@@/form-components/FileUpload';
|
||||||
|
import { FormControl } from '@@/form-components/FormControl';
|
||||||
|
import {
|
||||||
|
file,
|
||||||
|
withFileExtension,
|
||||||
|
} from '@@/form-components/yup-file-validation';
|
||||||
|
import { FormActions } from '@@/form-components/FormActions';
|
||||||
|
import { BEOverlay } from '@@/BEFeatureIndicator/BEOverlay';
|
||||||
|
|
||||||
|
import { FeatureId } from '../../feature-flags/enums';
|
||||||
|
|
||||||
|
import { useUpdateSSLConfigMutation } from './useUpdateSSLConfigMutation';
|
||||||
|
|
||||||
|
interface FormValues {
|
||||||
|
clientCertFile: File | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HelmCertPanel() {
|
||||||
|
const mutation = useUpdateSSLConfigMutation();
|
||||||
|
const initialValues = {
|
||||||
|
clientCertFile: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="row">
|
||||||
|
<div className="col-sm-12">
|
||||||
|
<BEOverlay featureId={FeatureId.CA_FILE}>
|
||||||
|
<Widget>
|
||||||
|
<Widget.Title
|
||||||
|
icon={Key}
|
||||||
|
title="Certificate Authority file for Kubernetes Helm repositories"
|
||||||
|
/>
|
||||||
|
<Widget.Body>
|
||||||
|
<Formik
|
||||||
|
initialValues={initialValues}
|
||||||
|
validationSchema={validation}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
>
|
||||||
|
<InnerForm isLoading={mutation.isLoading} />
|
||||||
|
</Formik>
|
||||||
|
</Widget.Body>
|
||||||
|
</Widget>
|
||||||
|
</BEOverlay>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
function handleSubmit({ clientCertFile }: FormValues) {
|
||||||
|
if (!clientCertFile) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
mutation.mutate(
|
||||||
|
{ clientCertFile },
|
||||||
|
{
|
||||||
|
onSuccess() {
|
||||||
|
notifySuccess('Success', 'Helm certificate updated');
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function InnerForm({ isLoading }: { isLoading: boolean }) {
|
||||||
|
const { values, setFieldValue, errors, isValid } =
|
||||||
|
useFormikContext<FormValues>();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form className="form-horizontal">
|
||||||
|
<div className="form-group">
|
||||||
|
<div className="col-sm-12">
|
||||||
|
<TextTip color="blue">
|
||||||
|
Provide an additional CA file containing certificate(s) for HTTPS
|
||||||
|
connections to Helm repositories.
|
||||||
|
</TextTip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FormControl
|
||||||
|
label="CA file"
|
||||||
|
tooltip="Select a CA file containing your X.509 certificate(s), commonly a crt, cer or pem file."
|
||||||
|
inputId="ca-cert-field"
|
||||||
|
errors={errors?.clientCertFile}
|
||||||
|
>
|
||||||
|
<FileUploadField
|
||||||
|
required
|
||||||
|
inputId="ca-cert-field"
|
||||||
|
name="clientCertFile"
|
||||||
|
onChange={(file) => setFieldValue('clientCertFile', file)}
|
||||||
|
value={values.clientCertFile}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<FormActions
|
||||||
|
isValid={isValid}
|
||||||
|
isLoading={isLoading}
|
||||||
|
submitLabel="Apply changes"
|
||||||
|
loadingText="Saving in progress..."
|
||||||
|
/>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function validation(): SchemaOf<FormValues> {
|
||||||
|
return object({
|
||||||
|
clientCertFile: withFileExtension(file(), [
|
||||||
|
'pem',
|
||||||
|
'crt',
|
||||||
|
'cer',
|
||||||
|
'cert',
|
||||||
|
]).required(),
|
||||||
|
});
|
||||||
|
}
|
|
@ -0,0 +1,43 @@
|
||||||
|
import { useMutation } from 'react-query';
|
||||||
|
|
||||||
|
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||||
|
import { mutationOptions, withError } from '@/react-tools/react-query';
|
||||||
|
|
||||||
|
export function useUpdateSSLConfigMutation() {
|
||||||
|
return useMutation(
|
||||||
|
updateSSLConfig,
|
||||||
|
mutationOptions(withError('Unable to update SSL configuration'))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SSLConfig {
|
||||||
|
// SSL Certificates
|
||||||
|
certFile?: File;
|
||||||
|
keyFile?: File;
|
||||||
|
httpEnabled?: boolean;
|
||||||
|
|
||||||
|
// SSL Client Certificates
|
||||||
|
clientCertFile?: File;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateSSLConfig({
|
||||||
|
certFile,
|
||||||
|
keyFile,
|
||||||
|
clientCertFile,
|
||||||
|
...payload
|
||||||
|
}: SSLConfig) {
|
||||||
|
try {
|
||||||
|
const cert = certFile ? await certFile.text() : undefined;
|
||||||
|
const key = keyFile ? await keyFile.text() : undefined;
|
||||||
|
const clientCert = clientCertFile ? await clientCertFile.text() : undefined;
|
||||||
|
|
||||||
|
await axios.put('/ssl', {
|
||||||
|
...payload,
|
||||||
|
cert,
|
||||||
|
key,
|
||||||
|
clientCert,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
throw parseAxiosError(error, 'Unable to update SSL configuration');
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue