refactor(settings): migrate helm cert panel to react [EE-5505] (#9132)

pull/9135/head
Chaim Lev-Ari 2023-06-29 13:31:17 +07:00 committed by GitHub
parent c452de82b7
commit f293ea41d3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 268 additions and 70 deletions

View File

@ -10,6 +10,7 @@ import (
)
type sslUpdatePayload struct {
// SSL Certificates
Cert *string
Key *string
HTTPEnabled *bool

View File

@ -1,9 +1,9 @@
export default class PortainerError extends Error {
err?: Error;
err?: unknown;
isPortainerError = true;
constructor(msg: string, err?: Error) {
constructor(msg: string, err?: unknown) {
super(msg);
this.err = err;
}

View File

@ -10,4 +10,5 @@ export const fileUploadField = r2a(FileUploadField, [
'accept',
'inputId',
'color',
'name',
]);

View File

@ -8,6 +8,7 @@ import { withReactQuery } from '@/react-tools/withReactQuery';
import { withUIRouter } from '@/react-tools/withUIRouter';
import { ApplicationSettingsPanel } from '@/react/portainer/settings/SettingsView/ApplicationSettingsPanel';
import { KubeSettingsPanel } from '@/react/portainer/settings/SettingsView/KubeSettingsPanel';
import { HelmCertPanel } from '@/react/portainer/settings/SettingsView/HelmCertPanel';
export const settingsModule = angular
.module('portainer.app.react.components.settings', [])
@ -24,6 +25,7 @@ export const settingsModule = angular
'applicationSettingsPanel',
r2a(withReactQuery(ApplicationSettingsPanel), ['onSuccess'])
)
.component('helmCertPanel', r2a(withReactQuery(HelmCertPanel), []))
.component(
'kubeSettingsPanel',
r2a(withUIRouter(withReactQuery(KubeSettingsPanel)), [])

View File

@ -57,14 +57,14 @@ axios.interceptors.request.use(agentInterceptor);
* @returns A PortainerError with the parsed error message and details.
*/
export function parseAxiosError(
err: Error,
err: unknown,
msg = '',
parseError = defaultErrorParser
) {
let resultErr = err;
let resultMsg = msg;
if ('isAxiosError' in err) {
if (isAxiosError(err)) {
const { error, details } = parseError(err as AxiosError);
resultErr = error;
if (msg && details) {

View File

@ -1,6 +1,5 @@
import angular from 'angular';
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;

View File

@ -1,6 +0,0 @@
import controller from './ssl-ca-file-settings-controller.js';
export const sslCaFileSettings = {
templateUrl: './ssl-ca-file-settings.html',
controller,
};

View File

@ -1,9 +0,0 @@
import { FeatureId } from '@/react/portainer/feature-flags/enums';
class SslCaFileSettingsController {
/* @ngInject */
constructor() {
this.limitedFeature = FeatureId.CA_FILE;
}
}
export default SslCaFileSettingsController;

View File

@ -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>

View File

@ -1,6 +1,6 @@
<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="limited-be-link vertical-center"
><be-feature-indicator feature="$ctrl.limitedFeature"></be-feature-indicator
@ -33,7 +33,7 @@
</div>
</div>
<div class="be-indicator-container limited-be">
<div class="be-indicator-container limited-be mx-4">
<div class="overlay">
<div class="limited-be-link vertical-center"
><be-feature-indicator feature="$ctrl.limitedFeature"></be-feature-indicator

View File

@ -1,6 +1,6 @@
<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="limited-be-link vertical-center"
><be-feature-indicator feature="$ctrl.limitedFeature"></be-feature-indicator
@ -32,7 +32,7 @@
</div>
</div>
<div class="be-indicator-container limited-be">
<div class="be-indicator-container limited-be mx-4">
<div class="overlay">
<div class="limited-be-link vertical-center"
><be-feature-indicator feature="$ctrl.limitedFeature"></be-feature-indicator

View File

@ -4,7 +4,8 @@
<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>
<div class="row">

View File

@ -22,5 +22,4 @@
.be-indicator-container {
border: solid 1px var(--BE-only);
margin: 15px;
}

View File

@ -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>
);
}

View File

@ -14,6 +14,7 @@ export interface Props {
required?: boolean;
inputId: string;
color?: ComponentProps<typeof Button>['color'];
name?: string;
}
export function FileUploadField({
@ -24,6 +25,7 @@ export function FileUploadField({
required = false,
inputId,
color = 'primary',
name,
}: Props) {
const fileRef = createRef<HTMLInputElement>();
@ -38,6 +40,7 @@ export function FileUploadField({
className={styles.fileInput}
onChange={changeHandler}
aria-label="file-input"
name={name}
/>
<Button
size="small"

View File

@ -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>
);
}

View File

@ -22,3 +22,24 @@ export function withFileSize(fileValidation: FileSchema, maxSize: number) {
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 || '');
}
}

View File

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

View File

@ -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');
}
}