1
0
Fork 0
mirror of https://github.com/portainer/portainer.git synced 2025-08-05 22:05:23 +02:00

refactor(app): move settings components to react [EE-3442] (#7625)

This commit is contained in:
Chaim Lev-Ari 2022-09-21 09:14:29 +03:00 committed by GitHub
parent 5777c18297
commit 1e21961e6a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
66 changed files with 488 additions and 96 deletions

View file

@ -1,4 +1,4 @@
import { usePublicSettings } from '@/portainer/settings/queries';
import { usePublicSettings } from '@/react/portainer/settings/queries';
import { Icon } from '@@/Icon';

View file

@ -1,7 +1,7 @@
import _ from 'lodash';
import { useInfo } from 'Docker/services/system.service';
import { EnvironmentId } from 'Portainer/environments/types';
import { useInfo } from '@/docker/services/system.service';
import { EnvironmentId } from '@/portainer/environments/types';
import { ResourceControlViewModel } from '@/react/portainer/access-control/models/ResourceControlViewModel';
import { DockerContainer, ContainerStatus } from './types';

View file

@ -1,6 +1,6 @@
import { Fragment } from 'react';
import DockerNetworkHelper from 'Docker/helpers/networkHelper';
import DockerNetworkHelper from '@/docker/helpers/networkHelper';
import { Authorized } from '@/portainer/hooks/useUser';
import { Table, TableContainer, TableTitle } from '@@/datatables';

View file

@ -1,11 +1,10 @@
import { notifySuccess } from '@/portainer/services/notifications';
import { FeatureId } from '@/portainer/feature-flags/enums';
import { isLimitedToBE } from '@/portainer/feature-flags/feature-flags.service';
import {
usePublicSettings,
useUpdateDefaultRegistrySettingsMutation,
} from 'Portainer/settings/queries';
import { notifySuccess } from 'Portainer/services/notifications';
import { FeatureId } from 'Portainer/feature-flags/enums';
import { isLimitedToBE } from '@/portainer/feature-flags/feature-flags.service';
} from '@/react/portainer/settings/queries';
import { Tooltip } from '@@/Tip/Tooltip';
import { Button } from '@@/buttons';

View file

@ -1,5 +1,6 @@
import clsx from 'clsx';
import { usePublicSettings } from 'Portainer/settings/queries';
import { usePublicSettings } from '@/react/portainer/settings/queries';
export function DefaultRegistryDomain() {
const settingsQuery = usePublicSettings({

View file

@ -1,6 +1,7 @@
import { usePublicSettings } from 'Portainer/settings/queries';
import clsx from 'clsx';
import { usePublicSettings } from '@/react/portainer/settings/queries';
export function DefaultRegistryName() {
const settingsQuery = usePublicSettings({
select: (settings) => settings.DefaultRegistry?.Hide,

View file

@ -0,0 +1,30 @@
import { ReactNode } from 'react';
export interface Props {
value: string;
icon?: ReactNode;
color: string;
}
// Helper function used as workaround to add opacity to the background color
function setOpacity(hex: string, alpha: number) {
return `${hex}${Math.floor(alpha * 255)
.toString(16)
.padStart(2, '0')}`;
}
export function Badge({ icon, value, color }: Props) {
return (
<span
className="badge inline-flex items-center"
style={{
backgroundColor: setOpacity(color, 0.1),
color,
padding: '5px 10px',
}}
>
{icon}
{value}
</span>
);
}

View file

@ -0,0 +1,69 @@
import { confirmDestructive } from '@/portainer/services/modal.service/confirm';
import { Settings } from '@/react/portainer/settings/types';
import { FormSectionTitle } from '@@/form-components/FormSectionTitle';
import { PasswordLengthSlider } from './PasswordLengthSlider/PasswordLengthSlider';
import { SaveAuthSettingsButton } from './SaveAuthSettingsButton';
export interface Props {
onSaveSettings(): void;
isLoading: boolean;
value: Settings['InternalAuthSettings'];
onChange(value: number): void;
}
export function InternalAuth({
onSaveSettings,
isLoading,
value,
onChange,
}: Props) {
function onSubmit() {
if (value.RequiredPasswordLength < 10) {
confirmDestructive({
title: 'Allow weak passwords?',
message:
'You have set an insecure minimum password length. This could leave your system vulnerable to attack, are you sure?',
buttons: {
confirm: {
label: 'Yes',
className: 'btn-danger',
},
},
callback: function onConfirm(confirmed) {
if (confirmed) onSaveSettings();
},
});
} else {
onSaveSettings();
}
}
return (
<>
<FormSectionTitle>Information</FormSectionTitle>
<div className="form-group col-sm-12 text-muted small">
When using internal authentication, Portainer will encrypt user
passwords and store credentials locally.
</div>
<FormSectionTitle>Password rules</FormSectionTitle>
<div className="form-group col-sm-12 text-muted small">
Define minimum length for user-generated passwords.
</div>
<div className="form-group">
<PasswordLengthSlider
min={1}
max={18}
step={1}
value={value.RequiredPasswordLength}
onChange={onChange}
/>
</div>
<SaveAuthSettingsButton onSubmit={onSubmit} isLoading={isLoading} />
</>
);
}

View file

@ -0,0 +1,21 @@
.slider-badge {
top: -2px;
}
.root {
margin-left: 15px;
margin-top: 200px;
}
.root :global(.rc-slider-tooltip-inner) {
height: auto;
padding: 10px;
background-color: var(--bg-tooltip-color);
min-width: max-content;
color: var(--text-tooltip-color);
box-shadow: var(--shadow-box-color);
}
.root :global(.rc-slider-tooltip-arrow) {
border-top-color: var(--bg-tooltip-color);
}

View file

@ -0,0 +1,125 @@
import RcSlider from 'rc-slider';
import clsx from 'clsx';
import { Lock, XCircle, CheckCircle } from 'react-feather';
import 'rc-slider/assets/index.css';
import { Badge } from '../Badge';
import styles from './PasswordLengthSlider.module.css';
export interface Props {
min: number;
max: number;
step: number;
value: number;
onChange(value: number): void;
}
type Strength = 'weak' | 'good' | 'strong' | 'veryStrong';
const sliderProperties: Record<
Strength,
{ strength: string; color: string; text: string }
> = {
weak: {
strength: 'weak',
color: '#F04438',
text: 'Weak password',
},
good: {
strength: 'good',
color: '#F79009',
text: 'Good password',
},
strong: {
strength: 'strong',
color: '#12B76A',
text: 'Strong password',
},
veryStrong: {
strength: 'veryStrong',
color: '#0BA5EC',
text: 'Very strong password',
},
};
const SliderWithTooltip = RcSlider.createSliderWithTooltip(RcSlider);
export function PasswordLengthSlider({
min,
max,
step,
value,
onChange,
}: Props) {
const sliderProps = getSliderProps(value);
function getSliderProps(value: number) {
if (value < 10) {
return sliderProperties.weak;
}
if (value < 12) {
return sliderProperties.good;
}
if (value < 14) {
return sliderProperties.strong;
}
return sliderProperties.veryStrong;
}
function getBadgeIcon(strength: string) {
switch (strength) {
case 'weak':
return <XCircle size="13" className="space-right" strokeWidth="3px" />;
case 'good':
case 'strong':
return (
<CheckCircle size="13" className="space-right" strokeWidth="3px" />
);
default:
return <Lock size="13" className="space-right" strokeWidth="3px" />;
}
}
function handleChange(sliderValue: number) {
onChange(sliderValue);
}
return (
<div className={clsx(styles.root, styles[sliderProps.strength])}>
<div className="col-sm-4">
<SliderWithTooltip
tipFormatter={(value) => `${value} characters`}
min={min}
max={max}
step={step}
defaultValue={12}
value={value}
onChange={handleChange}
handleStyle={{
height: 25,
width: 25,
borderWidth: 1.85,
borderColor: sliderProps.color,
top: 1.5,
boxShadow: 'none',
}}
railStyle={{ height: 10 }}
trackStyle={{ backgroundColor: sliderProps.color, height: 10 }}
/>
</div>
<div className={clsx('col-sm-2', styles.sliderBadge)}>
<Badge
icon={getBadgeIcon(sliderProps.strength)}
value={sliderProps.text}
color={sliderProps.color}
/>
</div>
</div>
);
}

View file

@ -0,0 +1,26 @@
import { LoadingButton } from '@@/buttons/LoadingButton';
import { FormSectionTitle } from '@@/form-components/FormSectionTitle';
export interface Props {
onSubmit(): void;
isLoading: boolean;
}
export function SaveAuthSettingsButton({ onSubmit, isLoading }: Props) {
return (
<>
<FormSectionTitle>Actions</FormSectionTitle>
<div className="form-group">
<div className="col-sm-12">
<LoadingButton
loadingText="Saving..."
isLoading={isLoading}
onClick={() => onSubmit()}
>
Save settings
</LoadingButton>
</div>
</div>
</>
);
}

View file

@ -0,0 +1 @@
export { InternalAuth } from './InternalAuth';

View file

@ -0,0 +1,125 @@
import { Field, Form, Formik } from 'formik';
import * as yup from 'yup';
import { useCallback, useEffect } from 'react';
import { baseHref } from '@/portainer/helpers/pathHelper';
import { notifySuccess } from '@/portainer/services/notifications';
import { useUpdateSettingsMutation } from '@/react/portainer/settings/queries';
import { LoadingButton } from '@@/buttons/LoadingButton';
import { FormControl } from '@@/form-components/FormControl';
import { FormSectionTitle } from '@@/form-components/FormSectionTitle';
import { Input } from '@@/form-components/Input';
import { Settings } from '../types';
import { EnabledWaitingRoomSwitch } from './EnableWaitingRoomSwitch';
interface FormValues {
EdgePortainerUrl: string;
TrustOnFirstConnect: boolean;
}
const validation = yup.object({
TrustOnFirstConnect: yup.boolean(),
EdgePortainerUrl: yup
.string()
.test(
'url',
'URL should be a valid URI and cannot include localhost',
(value) => {
if (!value) {
return false;
}
try {
const url = new URL(value);
return !!url.hostname && url.hostname !== 'localhost';
} catch {
return false;
}
}
)
.required('URL is required'),
});
interface Props {
settings: Settings;
}
const defaultUrl = buildDefaultUrl();
export function AutoEnvCreationSettingsForm({ settings }: Props) {
const url = settings.EdgePortainerUrl;
const initialValues = {
EdgePortainerUrl: url || defaultUrl,
TrustOnFirstConnect: settings.TrustOnFirstConnect,
};
const mutation = useUpdateSettingsMutation();
const { mutate: updateSettings } = mutation;
const handleSubmit = useCallback(
(variables: Partial<FormValues>) => {
updateSettings(variables, {
onSuccess() {
notifySuccess(
'Success',
'Successfully updated Automatic Environment Creation settings'
);
},
});
},
[updateSettings]
);
useEffect(() => {
if (!url && validation.isValidSync({ EdgePortainerUrl: defaultUrl })) {
updateSettings({ EdgePortainerUrl: defaultUrl });
}
}, [updateSettings, url]);
return (
<Formik<FormValues>
initialValues={initialValues}
onSubmit={handleSubmit}
validationSchema={validation}
validateOnMount
enableReinitialize
>
{({ errors, isValid, dirty }) => (
<Form className="form-horizontal">
<FormSectionTitle>Configuration</FormSectionTitle>
<FormControl
label="Portainer URL"
tooltip="URL of the Portainer instance that the agent will use to initiate the communications."
inputId="url-input"
errors={errors.EdgePortainerUrl}
>
<Field as={Input} id="url-input" name="EdgePortainerUrl" />
</FormControl>
<EnabledWaitingRoomSwitch />
<div className="form-group">
<div className="col-sm-12">
<LoadingButton
loadingText="generating..."
isLoading={mutation.isLoading}
disabled={!isValid || !dirty}
>
Save settings
</LoadingButton>
</div>
</div>
</Form>
)}
</Formik>
);
}
function buildDefaultUrl() {
const baseHREF = baseHref();
return window.location.origin + (baseHREF !== '/' ? baseHREF : '');
}

View file

@ -0,0 +1,71 @@
import { useMutation } from 'react-query';
import { useEffect } from 'react';
import { generateKey } from '@/portainer/environments/environment.service/edge';
import { EdgeScriptForm } from '@/react/edge/components/EdgeScriptForm';
import { commandsTabs } from '@/react/edge/components/EdgeScriptForm/scripts';
import { Widget, WidgetBody, WidgetTitle } from '@@/Widget';
import { useSettings } from '../../queries';
import { AutoEnvCreationSettingsForm } from './AutoEnvCreationSettingsForm';
const commands = {
linux: [
commandsTabs.k8sLinux,
commandsTabs.swarmLinux,
commandsTabs.standaloneLinux,
commandsTabs.nomadLinux,
],
win: [commandsTabs.swarmWindows, commandsTabs.standaloneWindow],
};
export function AutomaticEdgeEnvCreation() {
const edgeKeyMutation = useGenerateKeyMutation();
const { mutate: generateKey } = edgeKeyMutation;
const settingsQuery = useSettings();
const url = settingsQuery.data?.EdgePortainerUrl;
useEffect(() => {
if (url) {
generateKey();
}
}, [generateKey, url]);
if (!settingsQuery.data) {
return null;
}
const edgeKey = edgeKeyMutation.data;
return (
<Widget>
<WidgetTitle
icon="svg-laptop"
title="Automatic Edge Environment Creation"
/>
<WidgetBody>
<AutoEnvCreationSettingsForm settings={settingsQuery.data} />
{edgeKeyMutation.isLoading ? (
<div>Generating key for {url} ... </div>
) : (
edgeKey && (
<EdgeScriptForm
edgeInfo={{ key: edgeKey }}
commands={commands}
isNomadTokenVisible
/>
)
)}
</WidgetBody>
</Widget>
);
}
// using mutation because we want this action to run only when required
function useGenerateKeyMutation() {
return useMutation(generateKey);
}

View file

@ -0,0 +1,51 @@
import { useField } from 'formik';
import { confirmAsync } from '@/portainer/services/modal.service/confirm';
import { FormControl } from '@@/form-components/FormControl';
import { Switch } from '@@/form-components/SwitchField/Switch';
export function EnabledWaitingRoomSwitch() {
const [inputProps, meta, helpers] = useField<boolean>('TrustOnFirstConnect');
return (
<FormControl
inputId="edge_waiting_room"
label="Disable Edge Environment Waiting Room"
errors={meta.error}
>
<Switch
id="edge_waiting_room"
name="TrustOnFirstConnect"
className="space-right"
checked={inputProps.value}
onChange={handleChange}
/>
</FormControl>
);
async function handleChange(trust: boolean) {
if (!trust) {
helpers.setValue(false);
return;
}
const confirmed = await confirmAsync({
title: 'Disable Edge Environment Waiting Room',
message:
'By disabling the waiting room feature, all devices requesting association will be automatically associated and could pose a security risk. Are you sure?',
buttons: {
cancel: {
label: 'Cancel',
className: 'btn-default',
},
confirm: {
label: 'Confirm',
className: 'btn-danger',
},
},
});
helpers.setValue(!!confirmed);
}
}

View file

@ -0,0 +1 @@
export { AutomaticEdgeEnvCreation } from './AutomaticEdgeEnvCreation';

View file

@ -0,0 +1,142 @@
import { Form, Formik } from 'formik';
import { useReducer } from 'react';
import { EdgeCheckinIntervalField } from '@/edge/components/EdgeCheckInIntervalField';
import { EdgeAsyncIntervalsForm } from '@/edge/components/EdgeAsyncIntervalsForm';
import { notifySuccess } from '@/portainer/services/notifications';
import { FormControl } from '@@/form-components/FormControl';
import { Switch } from '@@/form-components/SwitchField/Switch';
import { Widget, WidgetBody, WidgetTitle } from '@@/Widget';
import { FormSection } from '@@/form-components/FormSection';
import { LoadingButton } from '@@/buttons/LoadingButton';
import { TextTip } from '@@/Tip/TextTip';
import { useSettings, useUpdateSettingsMutation } from '../../queries';
import { FormValues } from './types';
const asyncIntervalFieldSettings = {
ping: {
label: 'Edge agent default ping frequency',
tooltip:
'Interval used by default by each Edge agent to ping the Portainer instance. Affects Edge environment management and Edge compute features.',
},
snapshot: {
label: 'Edge agent default snapshot frequency',
tooltip:
'Interval used by default by each Edge agent to snapshot the agent state.',
},
command: {
label: 'Edge agent default command frequency',
tooltip: 'Interval used by default by each Edge agent to execute commands.',
},
};
export function DeploymentSyncOptions() {
const settingsQuery = useSettings();
const settingsMutation = useUpdateSettingsMutation();
const [formKey, resetForm] = useReducer((state) => state + 1, 0);
if (!settingsQuery.data) {
return null;
}
const initialValues = {
Edge: settingsQuery.data.Edge,
EdgeAgentCheckinInterval: settingsQuery.data.EdgeAgentCheckinInterval,
};
return (
<div className="row">
<Widget>
<WidgetTitle icon="svg-laptop" title="Deployment sync options" />
<WidgetBody>
<Formik<FormValues>
initialValues={initialValues}
onSubmit={handleSubmit}
key={formKey}
>
{({ errors, setFieldValue, values, isValid, dirty }) => (
<Form className="form-horizontal">
<FormControl
inputId="edge_async_mode"
label="Use Async mode by default"
size="small"
errors={errors?.Edge?.AsyncMode}
tooltip="Using Async allows the ability to define different ping,
snapshot and command frequencies."
>
<Switch
id="edge_async_mode"
name="edge_async_mode"
className="space-right"
checked={values.Edge.AsyncMode}
onChange={(e) =>
setFieldValue('Edge.AsyncMode', e.valueOf())
}
/>
</FormControl>
<TextTip color="orange">
Enabling Async disables the tunnel function.
</TextTip>
<FormSection title="Check-in Intervals">
{!values.Edge.AsyncMode ? (
<EdgeCheckinIntervalField
value={values.EdgeAgentCheckinInterval}
onChange={(value) =>
setFieldValue('EdgeAgentCheckinInterval', value)
}
isDefaultHidden
label="Edge agent default poll frequency"
tooltip="Interval used by default by each Edge agent to check in with the Portainer instance. Affects Edge environment management and Edge compute features."
/>
) : (
<EdgeAsyncIntervalsForm
values={values.Edge}
onChange={(value) => setFieldValue('Edge', value)}
isDefaultHidden
fieldSettings={asyncIntervalFieldSettings}
/>
)}
</FormSection>
<FormSection title="Actions">
<div className="form-group mt-5">
<div className="col-sm-12">
<LoadingButton
disabled={!isValid || !dirty}
data-cy="settings-deploySyncOptionsButton"
isLoading={settingsMutation.isLoading}
loadingText="Saving settings..."
>
Save settings
</LoadingButton>
</div>
</div>
</FormSection>
</Form>
)}
</Formik>
</WidgetBody>
</Widget>
</div>
);
function handleSubmit(values: FormValues) {
settingsMutation.mutate(
{
Edge: values.Edge,
EdgeAgentCheckinInterval: values.EdgeAgentCheckinInterval,
},
{
onSuccess() {
notifySuccess('Success', 'Settings updated successfully');
resetForm();
},
}
);
}
}

View file

@ -0,0 +1,9 @@
export interface FormValues {
Edge: {
PingInterval: number;
SnapshotInterval: number;
CommandInterval: number;
AsyncMode: boolean;
};
EdgeAgentCheckinInterval: number;
}

View file

@ -0,0 +1,25 @@
import { boolean, number, object, SchemaOf } from 'yup';
import { options as asyncIntervalOptions } from '@/edge/components/EdgeAsyncIntervalsForm';
import { FormValues } from './types';
const intervals = asyncIntervalOptions.map((option) => option.value);
export function validationSchema(): SchemaOf<FormValues> {
return object({
EdgeAgentCheckinInterval: number().required('This field is required.'),
Edge: object({
PingInterval: number()
.required('This field is required.')
.oneOf(intervals),
SnapshotInterval: number()
.required('This field is required.')
.oneOf(intervals),
CommandInterval: number()
.required('This field is required.')
.oneOf(intervals),
AsyncMode: boolean().default(false),
}),
});
}

View file

@ -0,0 +1,129 @@
import { Formik, Form } from 'formik';
import { EdgeCheckinIntervalField } from '@/edge/components/EdgeCheckInIntervalField';
import { Switch } from '@@/form-components/SwitchField/Switch';
import { FormControl } from '@@/form-components/FormControl';
import { Widget, WidgetBody, WidgetTitle } from '@@/Widget';
import { LoadingButton } from '@@/buttons/LoadingButton';
import { TextTip } from '@@/Tip/TextTip';
import { FormSectionTitle } from '@@/form-components/FormSectionTitle';
import { Settings } from '../types';
import { validationSchema } from './EdgeComputeSettings.validation';
export interface FormValues {
EdgeAgentCheckinInterval: number;
EnableEdgeComputeFeatures: boolean;
EnforceEdgeID: boolean;
}
interface Props {
settings?: Settings;
onSubmit(values: FormValues): void;
}
export function EdgeComputeSettings({ settings, onSubmit }: Props) {
if (!settings) {
return null;
}
return (
<div className="row">
<Widget>
<WidgetTitle icon="svg-laptop" title="Edge Compute settings" />
<WidgetBody>
<Formik
initialValues={settings}
enableReinitialize
validationSchema={() => validationSchema()}
onSubmit={onSubmit}
validateOnMount
>
{({
values,
errors,
handleSubmit,
setFieldValue,
isSubmitting,
isValid,
dirty,
}) => (
<Form
className="form-horizontal"
onSubmit={handleSubmit}
noValidate
>
<FormControl
inputId="edge_enable"
label="Enable Edge Compute features"
size="small"
errors={errors.EnableEdgeComputeFeatures}
>
<Switch
id="edge_enable"
name="edge_enable"
className="space-right"
checked={values.EnableEdgeComputeFeatures}
onChange={(e) =>
setFieldValue('EnableEdgeComputeFeatures', e)
}
/>
</FormControl>
<TextTip color="blue">
When enabled, this will enable Portainer to execute Edge
Device features.
</TextTip>
<FormControl
inputId="edge_enforce_id"
label="Enforce use of Portainer generated Edge ID"
size="small"
tooltip="This setting only applies to manually created environments."
errors={errors.EnforceEdgeID}
>
<Switch
id="edge_enforce_id"
name="edge_enforce_id"
className="space-right"
checked={values.EnforceEdgeID}
onChange={(e) =>
setFieldValue('EnforceEdgeID', e.valueOf())
}
/>
</FormControl>
<FormSectionTitle>Check-in Intervals</FormSectionTitle>
<EdgeCheckinIntervalField
value={values.EdgeAgentCheckinInterval}
onChange={(value) =>
setFieldValue('EdgeAgentCheckinInterval', value)
}
isDefaultHidden
label="Edge agent default poll frequency"
tooltip="Interval used by default by each Edge agent to check in with the Portainer instance. Affects Edge environment management and Edge compute features."
/>
<div className="form-group mt-5">
<div className="col-sm-12">
<LoadingButton
disabled={!isValid || !dirty}
data-cy="settings-edgeComputeButton"
isLoading={isSubmitting}
loadingText="Saving settings..."
>
Save settings
</LoadingButton>
</div>
</div>
</Form>
)}
</Formik>
</WidgetBody>
</Widget>
</div>
);
}

View file

@ -0,0 +1,9 @@
import { boolean, number, object } from 'yup';
export function validationSchema() {
return object().shape({
EdgeAgentCheckinInterval: number().required('This field is required.'),
EnableEdgeComputeFeatures: boolean().required('This field is required.'),
EnforceEdgeID: boolean().required('This field is required.'),
});
}

View file

@ -0,0 +1 @@
export { EdgeComputeSettings } from './EdgeComputeSettings';

View file

@ -0,0 +1,7 @@
export interface Settings {
EdgeAgentCheckinInterval: number;
EnableEdgeComputeFeatures: boolean;
TrustOnFirstConnect: boolean;
EnforceEdgeID: boolean;
EdgePortainerUrl: string;
}

View file

@ -0,0 +1,19 @@
import { Settings } from '@/react/portainer/settings/types';
import { EdgeComputeSettings } from './EdgeComputeSettings';
import { AutomaticEdgeEnvCreation } from './AutomaticEdgeEnvCreation';
interface Props {
settings: Settings;
onSubmit(values: Settings): void;
}
export function EdgeComputeSettingsView({ settings, onSubmit }: Props) {
return (
<div className="row">
<EdgeComputeSettings settings={settings} onSubmit={onSubmit} />
{process.env.PORTAINER_EDITION === 'BE' && <AutomaticEdgeEnvCreation />}
</div>
);
}

View file

@ -0,0 +1,167 @@
import { useTable, usePagination, useSortBy } from 'react-table';
import { useRowSelectColumn } from '@lineup-lite/hooks';
import { Profile } from '@/portainer/hostmanagement/fdo/model';
import PortainerError from '@/portainer/error';
import { PaginationControls } from '@@/PaginationControls';
import { SelectedRowsCount } from '@@/datatables/SelectedRowsCount';
import { TableFooter } from '@@/datatables/TableFooter';
import { useTableSettings } from '@@/datatables/useTableSettings';
import { useRowSelect } from '@@/datatables/useRowSelect';
import {
Table,
TableContainer,
TableHeaderRow,
TableRow,
TableTitle,
} from '@@/datatables';
import {
PaginationTableSettings,
SortableTableSettings,
} from '@@/datatables/types-old';
import { useFDOProfiles } from './useFDOProfiles';
import { useColumns } from './columns';
import { FDOProfilesDatatableActions } from './FDOProfilesDatatableActions';
export interface FDOProfilesTableSettings
extends SortableTableSettings,
PaginationTableSettings {}
export interface FDOProfilesDatatableProps {
isFDOEnabled: boolean;
}
export function FDOProfilesDatatable({
isFDOEnabled,
}: FDOProfilesDatatableProps) {
const { settings, setTableSettings } =
useTableSettings<FDOProfilesTableSettings>();
const columns = useColumns();
const { isLoading, profiles, error } = useFDOProfiles();
const {
getTableProps,
getTableBodyProps,
headerGroups,
page,
prepareRow,
selectedFlatRows,
gotoPage,
setPageSize,
state: { pageIndex, pageSize },
} = useTable<Profile>(
{
defaultCanFilter: false,
columns,
data: profiles,
initialState: {
pageSize: settings.pageSize || 10,
sortBy: [settings.sortBy],
},
isRowSelectable() {
return isFDOEnabled;
},
selectColumnWidth: 5,
},
useSortBy,
usePagination,
useRowSelect,
useRowSelectColumn
);
const tableProps = getTableProps();
const tbodyProps = getTableBodyProps();
return (
<TableContainer>
<TableTitle icon="list" featherIcon label="Device Profiles">
<FDOProfilesDatatableActions
isFDOEnabled={isFDOEnabled}
selectedItems={selectedFlatRows.map((row) => row.original)}
/>
</TableTitle>
<Table
className={tableProps.className}
role={tableProps.role}
style={tableProps.style}
>
<thead>
{headerGroups.map((headerGroup) => {
const { key, className, role, style } =
headerGroup.getHeaderGroupProps();
return (
<TableHeaderRow<Profile>
key={key}
className={className}
role={role}
style={style}
headers={headerGroup.headers}
/>
);
})}
</thead>
<tbody
className={tbodyProps.className}
role={tbodyProps.role}
style={tbodyProps.style}
>
{!isLoading && profiles && profiles.length > 0 ? (
page.map((row) => {
prepareRow(row);
const { key, className, role, style } = row.getRowProps();
return (
<TableRow<Profile>
cells={row.cells}
key={key}
className={className}
role={role}
style={style}
/>
);
})
) : (
<tr>
<td colSpan={5} className="text-center text-muted">
{userMessage(isLoading, error)}
</td>
</tr>
)}
</tbody>
</Table>
<TableFooter>
<SelectedRowsCount value={selectedFlatRows.length} />
<PaginationControls
showAll
pageLimit={pageSize}
page={pageIndex + 1}
onPageChange={(p) => gotoPage(p - 1)}
totalCount={profiles ? profiles.length : 0}
onPageLimitChange={handlePageSizeChange}
/>
</TableFooter>
</TableContainer>
);
function handlePageSizeChange(pageSize: number) {
setPageSize(pageSize);
setTableSettings((settings) => ({ ...settings, pageSize }));
}
}
function userMessage(isLoading: boolean, error?: PortainerError) {
if (isLoading) {
return 'Loading...';
}
if (error) {
return error.message;
}
return 'No profiles found';
}

View file

@ -0,0 +1,125 @@
import { useQueryClient } from 'react-query';
import { useRouter } from '@uirouter/react';
import { Profile } from '@/portainer/hostmanagement/fdo/model';
import {
confirmAsync,
confirmDestructiveAsync,
} from '@/portainer/services/modal.service/confirm';
import * as notifications from '@/portainer/services/notifications';
import {
deleteProfile,
duplicateProfile,
} from '@/portainer/hostmanagement/fdo/fdo.service';
import { Link } from '@@/Link';
import { Button } from '@@/buttons';
interface Props {
isFDOEnabled: boolean;
selectedItems: Profile[];
}
export function FDOProfilesDatatableActions({
isFDOEnabled,
selectedItems,
}: Props) {
const router = useRouter();
const queryClient = useQueryClient();
return (
<div className="actionBar">
<Link to="portainer.endpoints.profile" className="space-left">
<Button disabled={!isFDOEnabled} icon="plus-circle" featherIcon>
Add Profile
</Button>
</Link>
<Button
disabled={!isFDOEnabled || selectedItems.length !== 1}
onClick={() => onDuplicateProfileClick()}
icon="plus-circle"
featherIcon
>
Duplicate
</Button>
<Button
disabled={!isFDOEnabled || selectedItems.length < 1}
color="danger"
onClick={() => onDeleteProfileClick()}
icon="trash-2"
featherIcon
>
Remove
</Button>
</div>
);
async function onDuplicateProfileClick() {
const confirmed = await confirmAsync({
title: 'Are you sure ?',
message: 'This action will duplicate the selected profile. Continue?',
buttons: {
confirm: {
label: 'Confirm',
className: 'btn-primary',
},
},
});
if (!confirmed) {
return;
}
try {
const profile = selectedItems[0];
const newProfile = await duplicateProfile(profile.id);
notifications.success('Profile successfully duplicated', profile.name);
router.stateService.go('portainer.endpoints.profile.edit', {
id: newProfile.id,
});
} catch (err) {
notifications.error(
'Failure',
err as Error,
'Unable to duplicate profile'
);
}
}
async function onDeleteProfileClick() {
const confirmed = await confirmDestructiveAsync({
title: 'Are you sure ?',
message: 'This action will delete the selected profile(s). Continue?',
buttons: {
confirm: {
label: 'Remove',
className: 'btn-danger',
},
},
});
if (!confirmed) {
return;
}
await Promise.all(
selectedItems.map(async (profile) => {
try {
await deleteProfile(profile.id);
notifications.success('Profile successfully removed', profile.name);
} catch (err) {
notifications.error(
'Failure',
err as Error,
'Unable to remove profile'
);
}
})
);
await queryClient.invalidateQueries('fdo_profiles');
}
}

View file

@ -0,0 +1,22 @@
import { TableSettingsProvider } from '@@/datatables/useTableSettings';
import {
FDOProfilesDatatable,
FDOProfilesDatatableProps,
} from './FDOProfilesDatatable';
export function FDOProfilesDatatableContainer({
...props
}: FDOProfilesDatatableProps) {
const defaultSettings = {
pageSize: 10,
sortBy: { id: 'name', desc: false },
};
return (
<TableSettingsProvider defaults={defaultSettings} storageKey="fdoProfiles">
{/* eslint-disable-next-line react/jsx-props-no-spreading */}
<FDOProfilesDatatable {...props} />
</TableSettingsProvider>
);
}

View file

@ -0,0 +1,14 @@
import { Column } from 'react-table';
import { isoDateFromTimestamp } from '@/portainer/filters/filters';
import { Profile } from '@/portainer/hostmanagement/fdo/model';
export const created: Column<Profile> = {
Header: 'Created',
accessor: 'dateCreated',
id: 'created',
Cell: ({ value }) => isoDateFromTimestamp(value),
disableFilters: true,
canHide: true,
Filter: () => null,
};

View file

@ -0,0 +1,8 @@
import { useMemo } from 'react';
import { created } from './created';
import { name } from './name';
export function useColumns() {
return useMemo(() => [name, created], []);
}

View file

@ -0,0 +1,30 @@
import { CellProps, Column } from 'react-table';
import { useSref } from '@uirouter/react';
import { Profile } from '@/portainer/hostmanagement/fdo/model';
export const name: Column<Profile> = {
Header: 'Name',
accessor: 'name',
id: 'name',
Cell: NameCell,
disableFilters: true,
Filter: () => null,
canHide: true,
sortType: 'string',
};
export function NameCell({
value: name,
row: { original: profile },
}: CellProps<Profile>) {
const linkProps = useSref('portainer.endpoints.profile.edit', {
id: profile.id,
});
return (
<a href={linkProps.href} onClick={linkProps.onClick} title={name}>
{name}
</a>
);
}

View file

@ -0,0 +1,30 @@
import { useEffect, useMemo } from 'react';
import { useQuery } from 'react-query';
import PortainerError from '@/portainer/error';
import * as notifications from '@/portainer/services/notifications';
import { getProfiles } from '@/portainer/hostmanagement/fdo/fdo.service';
export function useFDOProfiles() {
const { isLoading, data, isError, error } = useQuery('fdo_profiles', () =>
getProfiles()
);
useEffect(() => {
if (isError) {
notifications.error(
'Failure',
error as Error,
'Failed retrieving FDO profiles'
);
}
}, [isError, error]);
const profiles = useMemo(() => data || [], [data]);
return {
isLoading,
profiles,
error: isError ? (error as PortainerError) : undefined,
};
}

View file

@ -0,0 +1,3 @@
.fdo-table {
margin-top: 3em;
}

View file

@ -0,0 +1,187 @@
import { useEffect, useState } from 'react';
import { Formik, Field, Form } from 'formik';
import { FDOConfiguration } from '@/portainer/hostmanagement/fdo/model';
import { Switch } from '@@/form-components/SwitchField/Switch';
import { FormControl } from '@@/form-components/FormControl';
import { FormSectionTitle } from '@@/form-components/FormSectionTitle';
import { Widget, WidgetBody, WidgetTitle } from '@@/Widget';
import { LoadingButton } from '@@/buttons/LoadingButton';
import { TextTip } from '@@/Tip/TextTip';
import { Input } from '@@/form-components/Input';
import { FDOProfilesDatatableContainer } from '../FDOProfilesDatatable/FDOProfilesDatatableContainer';
import styles from './SettingsFDO.module.css';
import { validationSchema } from './SettingsFDO.validation';
export interface Settings {
fdoConfiguration: FDOConfiguration;
EnableEdgeComputeFeatures: boolean;
}
interface Props {
settings: Settings;
onSubmit(values: FDOConfiguration): void;
}
export function SettingsFDO({ settings, onSubmit }: Props) {
const fdoConfiguration = settings ? settings.fdoConfiguration : null;
const initialFDOEnabled = fdoConfiguration ? fdoConfiguration.enabled : false;
const [isFDOEnabled, setIsFDOEnabled] = useState(initialFDOEnabled);
useEffect(() => {
setIsFDOEnabled(settings?.fdoConfiguration?.enabled);
}, [settings]);
const initialValues = {
enabled: initialFDOEnabled,
ownerURL: fdoConfiguration ? fdoConfiguration.ownerURL : '',
ownerUsername: fdoConfiguration ? fdoConfiguration.ownerUsername : '',
ownerPassword: fdoConfiguration ? fdoConfiguration.ownerPassword : '',
};
const edgeComputeFeaturesEnabled = settings
? settings.EnableEdgeComputeFeatures
: false;
return (
<div className="row">
<Widget>
<WidgetTitle icon="svg-laptop" title="FDO" />
<WidgetBody>
<Formik
initialValues={initialValues}
onSubmit={onSubmit}
enableReinitialize
validationSchema={() => validationSchema()}
validateOnChange
validateOnMount
>
{({
values,
errors,
handleSubmit,
setFieldValue,
isSubmitting,
isValid,
dirty,
}) => (
<Form className="form-horizontal" onSubmit={handleSubmit}>
<FormControl
inputId="edge_enableFDO"
label="Enable FDO Management Service"
size="small"
errors={errors.enabled}
>
<Switch
id="edge_enableFDO"
name="edge_enableFDO"
className="space-right"
disabled={!edgeComputeFeaturesEnabled}
checked={edgeComputeFeaturesEnabled && values.enabled}
onChange={(e) => onChangedEnabled(e, setFieldValue)}
/>
</FormControl>
<TextTip color="blue">
When enabled, this will allow Portainer to interact with FDO
Services.
</TextTip>
{edgeComputeFeaturesEnabled && values.enabled && (
<>
<hr />
<FormControl
inputId="owner_url"
label="Owner Service Server"
errors={errors.ownerURL}
>
<Field
as={Input}
name="ownerURL"
id="owner_url"
placeholder="http://127.0.0.1:8042"
value={values.ownerURL}
data-cy="fdo-serverInput"
/>
</FormControl>
<FormControl
inputId="owner_username"
label="Owner Service Username"
errors={errors.ownerUsername}
>
<Field
as={Input}
name="ownerUsername"
id="owner_username"
placeholder="username"
value={values.ownerUsername}
data-cy="fdo-usernameInput"
/>
</FormControl>
<FormControl
inputId="owner_password"
label="Owner Service Password"
errors={errors.ownerPassword}
>
<Field
as={Input}
type="password"
name="ownerPassword"
id="owner_password"
placeholder="password"
value={values.ownerPassword}
data-cy="fdo-passwordInput"
/>
</FormControl>
</>
)}
<div className="form-group mt-5">
<div className="col-sm-12">
<LoadingButton
disabled={!isValid || !dirty}
data-cy="settings-fdoButton"
isLoading={isSubmitting}
loadingText="Saving settings..."
>
Save settings
</LoadingButton>
</div>
</div>
</Form>
)}
</Formik>
{edgeComputeFeaturesEnabled && isFDOEnabled && (
<div className={styles.fdoTable}>
<FormSectionTitle>Device Profiles</FormSectionTitle>
<TextTip color="blue">
Add, Edit and Manage the list of device profiles available
during FDO device setup
</TextTip>
<FDOProfilesDatatableContainer isFDOEnabled={initialFDOEnabled} />
</div>
)}
</WidgetBody>
</Widget>
</div>
);
async function onChangedEnabled(
e: boolean,
setFieldValue: (
field: string,
value: unknown,
shouldValidate?: boolean
) => void
) {
setIsFDOEnabled(e);
setFieldValue('enabled', e);
}
}

View file

@ -0,0 +1,18 @@
import { object, string } from 'yup';
export function validationSchema() {
return object().shape({
ownerURL: string().when('enabled', {
is: true,
then: string().required('Field is required'),
}),
ownerUsername: string().when('enabled', {
is: true,
then: string().required('Field is required'),
}),
ownerPassword: string().when('enabled', {
is: true,
then: string().required('Field is required'),
}),
});
}

View file

@ -0,0 +1 @@
export { SettingsFDO } from './SettingsFDO';

View file

@ -0,0 +1,267 @@
import { useState } from 'react';
import { Formik, Field, Form } from 'formik';
import { OpenAMTConfiguration } from '@/portainer/hostmanagement/open-amt/model';
import { Switch } from '@@/form-components/SwitchField/Switch';
import { FormControl } from '@@/form-components/FormControl';
import { Widget, WidgetBody, WidgetTitle } from '@@/Widget';
import { LoadingButton } from '@@/buttons/LoadingButton';
import { TextTip } from '@@/Tip/TextTip';
import { Input } from '@@/form-components/Input';
import { FileUploadField } from '@@/form-components/FileUpload';
import { validationSchema } from './SettingsOpenAMT.validation';
export interface Settings {
openAMTConfiguration: OpenAMTConfiguration;
EnableEdgeComputeFeatures: boolean;
}
interface Props {
settings: Settings;
onSubmit(values: OpenAMTConfiguration): void;
}
export function SettingsOpenAMT({ settings, onSubmit }: Props) {
const [certFile, setCertFile] = useState<File>();
async function handleFileUpload(
file: File,
setFieldValue: (
field: string,
value: unknown,
shouldValidate?: boolean
) => void
) {
if (file) {
setCertFile(file);
const fileContent = await readFileContent(file);
setFieldValue('certFileContent', fileContent);
setFieldValue('certFileName', file.name);
}
}
function readFileContent(file: File) {
return new Promise((resolve, reject) => {
const fileReader = new FileReader();
fileReader.onload = (e) => {
if (e.target == null || e.target.result == null) {
resolve('');
return;
}
const base64 = e.target.result.toString();
// remove prefix of "data:application/x-pkcs12;base64," returned by "readAsDataURL()"
const index = base64.indexOf('base64,');
const cert = base64.substring(index + 7, base64.length);
resolve(cert);
};
fileReader.onerror = () => {
reject(new Error('error reading provisioning certificate file'));
};
fileReader.readAsDataURL(file);
});
}
const openAMTConfiguration = settings ? settings.openAMTConfiguration : null;
const initialValues = {
enabled: openAMTConfiguration ? openAMTConfiguration.enabled : false,
mpsServer: openAMTConfiguration ? openAMTConfiguration.mpsServer : '',
mpsUser: openAMTConfiguration ? openAMTConfiguration.mpsUser : '',
mpsPassword: openAMTConfiguration ? openAMTConfiguration.mpsPassword : '',
domainName: openAMTConfiguration ? openAMTConfiguration.domainName : '',
certFileContent: openAMTConfiguration
? openAMTConfiguration.certFileContent
: '',
certFileName: openAMTConfiguration ? openAMTConfiguration.certFileName : '',
certFilePassword: openAMTConfiguration
? openAMTConfiguration.certFilePassword
: '',
};
if (
initialValues.certFileContent &&
initialValues.certFileName &&
!certFile
) {
setCertFile(new File([], initialValues.certFileName));
}
const edgeComputeFeaturesEnabled = settings
? settings.EnableEdgeComputeFeatures
: false;
return (
<div className="row">
<Widget>
<WidgetTitle icon="svg-laptop" title="Intel OpenAMT" />
<WidgetBody>
<Formik
initialValues={initialValues}
onSubmit={onSubmit}
enableReinitialize
validationSchema={() => validationSchema()}
validateOnChange
validateOnMount
>
{({
values,
errors,
handleSubmit,
setFieldValue,
isSubmitting,
isValid,
dirty,
}) => (
<Form className="form-horizontal" onSubmit={handleSubmit}>
<FormControl
inputId="edge_enableOpenAMT"
label="Enable OpenAMT"
errors={errors.enabled}
size="small"
>
<Switch
id="edge_enableOpenAMT"
name="edge_enableOpenAMT"
className="space-right"
disabled={!edgeComputeFeaturesEnabled}
checked={edgeComputeFeaturesEnabled && values.enabled}
onChange={(e) => setFieldValue('enabled', e)}
/>
</FormControl>
<TextTip color="blue">
When enabled, this will allow Portainer to interact with an
OpenAMT MPS API.
</TextTip>
{edgeComputeFeaturesEnabled && values.enabled && (
<>
<hr />
<FormControl
inputId="mps_server"
label="MPS Server"
size="medium"
errors={errors.mpsServer}
>
<Field
as={Input}
name="mpsServer"
id="mps_server"
placeholder="Enter the MPS Server"
value={values.mpsServer}
data-cy="openAMT-serverInput"
/>
</FormControl>
<FormControl
inputId="mps_username"
label="MPS User"
size="medium"
errors={errors.mpsUser}
>
<Field
as={Input}
name="mpsUser"
id="mps_username"
placeholder="Enter the MPS User"
value={values.mpsUser}
data-cy="openAMT-usernameInput"
/>
</FormControl>
<FormControl
inputId="mps_password"
label="MPS Password"
size="medium"
tooltip="Needs to be 8-32 characters including one uppercase, one lowercase letters, one base-10 digit and one special character."
errors={errors.mpsPassword}
>
<Field
as={Input}
type="password"
name="mpsPassword"
id="mps_password"
placeholder="Enter the MPS Password"
value={values.mpsPassword}
data-cy="openAMT-passwordInput"
/>
</FormControl>
<hr />
<FormControl
inputId="domain_name"
label="Domain Name"
size="medium"
tooltip="Enter the FQDN that is associated with the provisioning certificate (i.e amtdomain.com)"
errors={errors.domainName}
>
<Field
as={Input}
name="domainName"
id="domain_name"
placeholder="Enter the Domain Name"
value={values.domainName}
data-cy="openAMT-domainInput"
/>
</FormControl>
<FormControl
inputId="certificate_file"
label="Provisioning Certificate File (.pfx)"
size="medium"
tooltip="Supported CAs are Comodo, DigiCert, Entrust and GoDaddy.<br>The certificate must contain the private key.<br>On AMT 15 based devices you need to use SHA2."
errors={errors.certFileContent}
>
<FileUploadField
inputId="certificate_file"
title="Upload file"
accept=".pfx"
value={certFile}
onChange={(file) =>
handleFileUpload(file, setFieldValue)
}
/>
</FormControl>
<FormControl
inputId="certificate_password"
label="Provisioning Certificate Password"
size="medium"
tooltip="Needs to be 8-32 characters including one uppercase, one lowercase letters, one base-10 digit and one special character."
errors={errors.certFilePassword}
>
<Field
as={Input}
type="password"
name="certFilePassword"
id="certificate_password"
placeholder="**********"
value={values.certFilePassword}
data-cy="openAMT-certPasswordInput"
/>
</FormControl>
</>
)}
<div className="form-group mt-5">
<div className="col-sm-12">
<LoadingButton
disabled={!isValid || !dirty}
data-cy="settings-fdoButton"
isLoading={isSubmitting}
loadingText="Saving settings..."
>
Save settings
</LoadingButton>
</div>
</div>
</Form>
)}
</Formik>
</WidgetBody>
</Widget>
</div>
);
}

View file

@ -0,0 +1,34 @@
import { object, string } from 'yup';
export function validationSchema() {
return object().shape({
mpsServer: string().when('enabled', {
is: true,
then: string().required('Field is required'),
}),
mpsUser: string().when('enabled', {
is: true,
then: string().required('Field is required'),
}),
mpsPassword: string().when('enabled', {
is: true,
then: string().required('Field is required'),
}),
domainName: string().when('enabled', {
is: true,
then: string().required('Field is required'),
}),
certFileContent: string().when('enabled', {
is: true,
then: string().required('Field is required'),
}),
certFileName: string().when('enabled', {
is: true,
then: string().required('Field is required'),
}),
certFilePassword: string().when('enabled', {
is: true,
then: string().required('Field is required'),
}),
});
}

View file

@ -0,0 +1 @@
export { SettingsOpenAMT } from './SettingsOpenAMT';

View file

@ -0,0 +1,7 @@
export interface Settings {
EdgeAgentCheckinInterval: number;
EnableEdgeComputeFeatures: boolean;
TrustOnFirstConnect: boolean;
EnforceEdgeID: boolean;
EdgePortainerUrl: string;
}

View file

@ -0,0 +1,68 @@
import { useMutation, useQuery, useQueryClient } from 'react-query';
import {
mutationOptions,
withError,
withInvalidate,
} from '@/react-tools/react-query';
import { PublicSettingsViewModel } from '@/portainer/models/settings';
import {
getSettings,
updateSettings,
getPublicSettings,
updateDefaultRegistry,
} from './settings.service';
import { DefaultRegistry, Settings } from './types';
export function usePublicSettings<T = PublicSettingsViewModel>({
enabled,
select,
onSuccess,
}: {
select?: (settings: PublicSettingsViewModel) => T;
enabled?: boolean;
onSuccess?: (data: T) => void;
} = {}) {
return useQuery(['settings', 'public'], () => getPublicSettings(), {
select,
...withError('Unable to retrieve public settings'),
enabled,
onSuccess,
});
}
export function useSettings<T = Settings>(
select?: (settings: Settings) => T,
enabled?: boolean
) {
return useQuery(['settings'], getSettings, {
select,
enabled,
...withError('Unable to retrieve settings'),
});
}
export function useUpdateSettingsMutation() {
const queryClient = useQueryClient();
return useMutation(
updateSettings,
mutationOptions(
withInvalidate(queryClient, [['settings'], ['cloud']]),
withError('Unable to update settings')
)
);
}
export function useUpdateDefaultRegistrySettingsMutation() {
const queryClient = useQueryClient();
return useMutation(
(payload: Partial<DefaultRegistry>) => updateDefaultRegistry(payload),
mutationOptions(
withInvalidate(queryClient, [['settings']]),
withError('Unable to update default registry settings')
)
);
}

View file

@ -0,0 +1,64 @@
import { PublicSettingsViewModel } from '@/portainer/models/settings';
import axios, { parseAxiosError } from '@/portainer/services/axios';
import { DefaultRegistry, PublicSettingsResponse, Settings } from './types';
export async function getPublicSettings() {
try {
const { data } = await axios.get<PublicSettingsResponse>(
buildUrl('public')
);
return new PublicSettingsViewModel(data);
} catch (e) {
throw parseAxiosError(
e as Error,
'Unable to retrieve application settings'
);
}
}
export async function getSettings() {
try {
const { data } = await axios.get<Settings>(buildUrl());
return data;
} catch (e) {
throw parseAxiosError(
e as Error,
'Unable to retrieve application settings'
);
}
}
export async function updateSettings(settings: Partial<Settings>) {
try {
await axios.put(buildUrl(), settings);
} catch (e) {
throw parseAxiosError(e as Error, 'Unable to update application settings');
}
}
export async function updateDefaultRegistry(
defaultRegistry: Partial<DefaultRegistry>
) {
try {
await axios.put(buildUrl('default_registry'), defaultRegistry);
} catch (e) {
throw parseAxiosError(
e as Error,
'Unable to update default registry settings'
);
}
}
function buildUrl(subResource?: string, action?: string) {
let url = 'settings';
if (subResource) {
url += `/${subResource}`;
}
if (action) {
url += `/${action}`;
}
return url;
}

View file

@ -0,0 +1,159 @@
import { TeamId } from '@/react/portainer/users/teams/types';
export interface FDOConfiguration {
enabled: boolean;
ownerURL: string;
ownerUsername: string;
ownerPassword: string;
}
export interface TLSConfiguration {
TLS: boolean;
TLSSkipVerify: boolean;
TLSCACert?: string;
TLSCert?: string;
TLSKey?: string;
}
export interface LDAPGroupSearchSettings {
GroupBaseDN: string;
GroupFilter: string;
GroupAttribute: string;
}
export interface LDAPSearchSettings {
BaseDN: string;
Filter: string;
UserNameAttribute: string;
}
export interface LDAPSettings {
AnonymousMode: boolean;
ReaderDN: string;
Password?: string;
URL: string;
TLSConfig: TLSConfiguration;
StartTLS: boolean;
SearchSettings: LDAPSearchSettings[];
GroupSearchSettings: LDAPGroupSearchSettings[];
AutoCreateUsers: boolean;
}
export interface Pair {
name: string;
value: string;
}
export interface OpenAMTConfiguration {
enabled: boolean;
mpsServer: string;
mpsUser: string;
mpsPassword: string;
mpsToken: string;
certFileName: string;
certFileContent: string;
certFilePassword: string;
domainName: string;
}
export interface OAuthSettings {
ClientID: string;
ClientSecret?: string;
AccessTokenURI: string;
AuthorizationURI: string;
ResourceURI: string;
RedirectURI: string;
UserIdentifier: string;
Scopes: string;
OAuthAutoCreateUsers: boolean;
DefaultTeamID: TeamId;
SSO: boolean;
LogoutURI: string;
KubeSecretKey: string;
}
enum AuthenticationMethod {
/**
* Internal represents the internal authentication method (authentication against Portainer API)
*/
Internal,
/**
* LDAP represents the LDAP authentication method (authentication against a LDAP server)
*/
LDAP,
/**
* OAuth represents the OAuth authentication method (authentication against a authorization server)
*/
OAuth,
}
type Feature = string;
export interface DefaultRegistry {
Hide: boolean;
}
export interface Settings {
LogoURL: string;
BlackListedLabels: Pair[];
AuthenticationMethod: AuthenticationMethod;
InternalAuthSettings: { RequiredPasswordLength: number };
LDAPSettings: LDAPSettings;
OAuthSettings: OAuthSettings;
openAMTConfiguration: OpenAMTConfiguration;
fdoConfiguration: FDOConfiguration;
FeatureFlagSettings: { [key: Feature]: boolean };
SnapshotInterval: string;
TemplatesURL: string;
EnableEdgeComputeFeatures: boolean;
UserSessionTimeout: string;
KubeconfigExpiry: string;
EnableTelemetry: boolean;
HelmRepositoryURL: string;
KubectlShellImage: string;
TrustOnFirstConnect: boolean;
EnforceEdgeID: boolean;
AgentSecret: string;
EdgePortainerUrl: string;
EdgeAgentCheckinInterval: number;
EdgeCommandInterval: number;
EdgePingInterval: number;
EdgeSnapshotInterval: number;
DisplayDonationHeader: boolean;
DisplayExternalContributors: boolean;
EnableHostManagementFeatures: boolean;
AllowVolumeBrowserForRegularUsers: boolean;
AllowBindMountsForRegularUsers: boolean;
AllowPrivilegedModeForRegularUsers: boolean;
AllowHostNamespaceForRegularUsers: boolean;
AllowStackManagementForRegularUsers: boolean;
AllowDeviceMappingForRegularUsers: boolean;
AllowContainerCapabilitiesForRegularUsers: boolean;
Edge: {
PingInterval: number;
SnapshotInterval: number;
CommandInterval: number;
AsyncMode: boolean;
};
}
export interface PublicSettingsResponse {
// URL to a logo that will be displayed on the login page as well as on top of the sidebar. Will use default Portainer logo when value is empty string
LogoURL: string;
// Active authentication method for the Portainer instance. Valid values are: 1 for internal, 2 for LDAP, or 3 for oauth
AuthenticationMethod: AuthenticationMethod;
// Whether edge compute features are enabled
EnableEdgeComputeFeatures: boolean;
// Supported feature flags
Features: Record<string, boolean>;
// The URL used for oauth login
OAuthLoginURI: string;
// The URL used for oauth logout
OAuthLogoutURI: string;
// Whether portainer internal auth view will be hidden
OAuthHideInternalAuth: boolean;
// Whether telemetry is enabled
EnableTelemetry: boolean;
// The expiry of a Kubeconfig
KubeconfigExpiry: string;
}

View file

@ -3,7 +3,7 @@ import { useMutation, useQueryClient } from 'react-query';
import { Trash2, Users } from 'react-feather';
import { confirmDeletionAsync } from '@/portainer/services/modal.service/confirm';
import { usePublicSettings } from '@/portainer/settings/queries';
import { usePublicSettings } from '@/react/portainer/settings/queries';
import {
mutationOptions,
withError,

View file

@ -2,7 +2,7 @@ import { useRouter } from '@uirouter/react';
import { useUsers } from '@/portainer/users/queries';
import { useUser } from '@/portainer/hooks/useUser';
import { usePublicSettings } from '@/portainer/settings/queries';
import { usePublicSettings } from '@/react/portainer/settings/queries';
import { TextTip } from '@@/Tip/TextTip';
import { PageHeader } from '@@/PageHeader';

View file

@ -7,7 +7,7 @@ import {
FileText,
} from 'react-feather';
import { usePublicSettings } from '@/portainer/settings/queries';
import { usePublicSettings } from '@/react/portainer/settings/queries';
import {
FeatureFlag,
useFeatureFlag,

View file

@ -3,7 +3,7 @@ import { Home } from 'react-feather';
import { useUser } from '@/portainer/hooks/useUser';
import { useIsTeamLeader } from '@/portainer/users/queries';
import { usePublicSettings } from '@/portainer/settings/queries';
import { usePublicSettings } from '@/react/portainer/settings/queries';
import styles from './Sidebar.module.css';
import { EdgeComputeSidebar } from './EdgeComputeSidebar';