jackson/components/saml/AddEdit.tsx

356 lines
13 KiB
TypeScript

import Link from 'next/link';
import { useRouter } from 'next/router';
import { FormEvent, useEffect, useState } from 'react';
import { mutate } from 'swr';
import { ArrowLeftIcon, CheckCircleIcon, ExclamationCircleIcon } from '@heroicons/react/outline';
import { Modal } from '@supabase/ui';
/**
* Edit view will have extra fields (showOnlyInEditView: true)
* to render parsed metadata and other attributes.
* All fields are editable unless they have `editable` set to false.
* All fields are required unless they have `required` or `requiredInEditView` set to false.
*/
const fieldCatalog = [
{
key: 'name',
label: 'Name',
type: 'text',
placeholder: 'MyApp',
attributes: { required: false, requiredInEditView: false },
},
{
key: 'description',
label: 'Description',
type: 'text',
placeholder: 'A short description not more than 100 characters',
attributes: { maxLength: 100, required: false, requiredInEditView: false }, // not required in create/edit view
},
{
key: 'tenant',
label: 'Tenant',
type: 'text',
placeholder: 'acme.com',
attributes: { editable: false },
},
{
key: 'product',
label: 'Product',
type: 'text',
placeholder: 'demo',
attributes: { editable: false },
},
{
key: 'redirectUrl',
label: 'Allowed redirect URLs (newline separated)',
type: 'textarea',
placeholder: 'http://localhost:3366',
attributes: { isArray: true, rows: 3 },
},
{
key: 'defaultRedirectUrl',
label: 'Default redirect URL',
type: 'url',
placeholder: 'http://localhost:3366/login/saml',
attributes: {},
},
{
key: 'rawMetadata',
label: 'Raw IdP XML',
type: 'textarea',
placeholder: 'Paste the raw XML here',
attributes: {
rows: 5,
requiredInEditView: false, //not required in edit view
labelInEditView: 'Raw IdP XML (fully replaces the current one)',
},
},
{
key: 'idpMetadata',
label: 'IDP Metadata',
type: 'pre',
attributes: {
rows: 10,
editable: false,
showOnlyInEditView: true,
formatForDisplay: (value) => JSON.stringify(value, null, 2),
},
},
{
key: 'clientID',
label: 'Client Id',
type: 'text',
attributes: { showOnlyInEditView: true },
},
{
key: 'clientSecret',
label: 'Client Secret',
type: 'password',
attributes: { showOnlyInEditView: true },
},
];
function getFieldList(isEditView) {
return isEditView
? fieldCatalog
: fieldCatalog.filter(({ attributes: { showOnlyInEditView } }) => !showOnlyInEditView); // filtered list for add view
}
function getInitialState(samlConfig, isEditView) {
const _state = {};
const _fieldCatalog = getFieldList(isEditView);
_fieldCatalog.forEach(({ key, attributes }) => {
_state[key] = samlConfig?.[key]
? attributes.isArray
? samlConfig[key].join('\r\n') // render list of items on newline eg:- redirect URLs
: samlConfig[key]
: '';
});
return _state;
}
type AddEditProps = {
samlConfig?: Record<string, any>;
};
const AddEdit = ({ samlConfig }: AddEditProps) => {
const router = useRouter();
const isEditView = !!samlConfig;
// FORM LOGIC: SUBMIT
const [{ status }, setSaveStatus] = useState<{ status: 'UNKNOWN' | 'SUCCESS' | 'ERROR' }>({
status: 'UNKNOWN',
});
const saveSAMLConfiguration = async (event) => {
event.preventDefault();
const { rawMetadata, redirectUrl, ...rest } = formObj;
const encodedRawMetadata = btoa(rawMetadata || '');
const redirectUrlList = redirectUrl.split(/\r\n|\r|\n/);
const res = await fetch('/api/admin/saml/config', {
method: isEditView ? 'PATCH' : 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: 'Api-Key secret',
},
body: JSON.stringify({ ...rest, encodedRawMetadata, redirectUrl: JSON.stringify(redirectUrlList) }),
});
if (res.ok) {
if (!isEditView) {
router.replace('/admin/saml/config');
} else {
setSaveStatus({ status: 'SUCCESS' });
// revalidate on save
mutate(`/api/admin/saml/config/${router.query.id}`);
setTimeout(() => setSaveStatus({ status: 'UNKNOWN' }), 2000);
}
} else {
// save failed
setSaveStatus({ status: 'ERROR' });
setTimeout(() => setSaveStatus({ status: 'UNKNOWN' }), 2000);
}
};
// LOGIC: DELETE
const [delModalVisible, setDelModalVisible] = useState(false);
const toggleDelConfirm = () => setDelModalVisible(!delModalVisible);
const [userNameEntry, setUserNameEntry] = useState('');
const deleteConfiguration = async () => {
await fetch('/api/admin/saml/config', {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
Authorization: 'Api-Key secret',
},
body: JSON.stringify({ clientID: samlConfig?.clientID, clientSecret: samlConfig?.clientSecret }),
});
toggleDelConfirm();
await mutate('/api/admin/saml/config');
router.replace('/admin/saml/config');
};
// STATE: FORM
const [formObj, setFormObj] = useState<Record<string, string>>(() =>
getInitialState(samlConfig, isEditView)
);
// Resync form state on save
useEffect(() => {
const _state = getInitialState(samlConfig, isEditView);
setFormObj(_state);
}, [samlConfig, isEditView]);
function handleChange(event: FormEvent) {
const target = event.target as HTMLInputElement | HTMLTextAreaElement;
setFormObj((cur) => ({ ...cur, [target.id]: target.value }));
}
return (
<>
{/* Or use router.back() */}
<Link href='/admin/saml/config'>
<a className='link-primary'>
<ArrowLeftIcon aria-hidden className='h-4 w-4' />
<span className='ml-2'>Back to Configurations</span>
</a>
</Link>
<div>
<h2 className='mt-2 mb-4 text-3xl font-bold text-primary dark:text-white'>
{samlConfig?.name || samlConfig?.product || 'New SAML Configuration'}
</h2>
<form onSubmit={saveSAMLConfiguration}>
<div className='min-w-[28rem] rounded-xl border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-800 md:w-3/4 md:max-w-lg'>
{fieldCatalog
.filter(({ attributes: { showOnlyInEditView } }) => (isEditView ? true : !showOnlyInEditView))
.map(
({
key,
placeholder,
label,
type,
attributes: {
isArray,
rows,
formatForDisplay,
editable,
requiredInEditView = true, // by default all fields are required unless explicitly set to false
labelInEditView,
maxLength,
required = true, // by default all fields are required unless explicitly set to false
},
}) => {
const readOnly = isEditView && editable === false;
const _required = isEditView ? !!requiredInEditView : !!required;
const _label = isEditView && labelInEditView ? labelInEditView : label;
const value =
readOnly && typeof formatForDisplay === 'function'
? formatForDisplay(formObj[key])
: formObj[key];
return (
<div className='mb-6 ' key={key}>
<label
htmlFor={key}
className='mb-2 block text-sm font-medium text-gray-900 dark:text-gray-300'>
{_label}
</label>
{type === 'pre' ? (
<pre className='block w-full overflow-auto rounded-lg border border-gray-300 bg-gray-50 p-2 text-sm text-gray-900 focus:border-blue-500 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder-gray-400 dark:focus:border-blue-500 dark:focus:ring-blue-500'>
{value}
</pre>
) : type === 'textarea' ? (
<textarea
id={key}
placeholder={placeholder}
value={value}
required={_required}
readOnly={readOnly}
maxLength={maxLength}
onChange={handleChange}
className={`block w-full rounded-lg border border-gray-300 bg-gray-50 p-2 text-sm text-gray-900 focus:border-blue-500 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder-gray-400 dark:focus:border-blue-500 dark:focus:ring-blue-500 ${
isArray ? 'whitespace-pre' : ''
}`}
rows={rows}
/>
) : (
<input
id={key}
type={type}
placeholder={placeholder}
value={value}
required={_required}
readOnly={readOnly}
maxLength={maxLength}
onChange={handleChange}
className='block w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 text-sm text-gray-900 focus:border-blue-500 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder-gray-400 dark:focus:border-blue-500 dark:focus:ring-blue-500'
/>
)}
</div>
);
}
)}
<div className='flex'>
<button type='submit' className='btn-primary'>
Save Changes
</button>
<p
role='status'
className={`ml-2 inline-flex items-center ${
status === 'SUCCESS' || status === 'ERROR' ? 'opacity-100' : 'opacity-0'
} transition-opacity motion-reduce:transition-none`}>
{status === 'SUCCESS' && (
<span className='inline-flex items-center text-primary'>
<CheckCircleIcon aria-hidden className='mr-1 h-5 w-5'></CheckCircleIcon>
Saved
</span>
)}
{/* TODO: also display error message once we standardise the response format */}
{status === 'ERROR' && (
<span className='inline-flex items-center text-red-900'>
<ExclamationCircleIcon aria-hidden className='mr-1 h-5 w-5'></ExclamationCircleIcon>
ERROR
</span>
)}
</p>
</div>
</div>
{samlConfig?.clientID && samlConfig.clientSecret && (
<section className='mt-10 flex items-center rounded bg-red-100 p-6 text-red-900'>
<div className='flex-1'>
<h6 className='mb-1 font-medium'>Delete this configuration</h6>
<p className='font-light'>All your apps using this configuration will stop working.</p>
</div>
<button
type='button'
className='inline-block rounded bg-red-700 px-4 py-2 text-sm font-bold leading-6 text-white hover:bg-red-800'
onClick={toggleDelConfirm}
data-modal-toggle='popup-modal'>
Delete
</button>
</section>
)}
</form>
<Modal
closable
title='Are you absolutely sure ?'
description='This action cannot be undone. This will permanently delete the SAML config.'
visible={delModalVisible}
onCancel={toggleDelConfirm}
customFooter={
<div className='ml-auto inline-flex'>
<button
type='button'
onClick={toggleDelConfirm}
className='inline-block rounded border-2 bg-gray-200 px-4 py-2 text-sm font-bold leading-6 text-secondary/90 hover:bg-gray-300'>
Cancel
</button>
<button
type='button'
disabled={userNameEntry !== samlConfig?.product}
onClick={deleteConfiguration}
className='ml-1.5 inline-block rounded bg-red-700 py-2 px-4 text-sm font-bold leading-6 text-white hover:bg-red-800 disabled:bg-slate-400'>
Delete
</button>
</div>
}>
<p className='text-slate-600'>
Please type in the name of the product &apos;
{samlConfig?.product && <strong>{samlConfig.product}</strong>}&apos; to confirm.
</p>
<label htmlFor='nameOfProd' className='font-medium text-slate-900'>
Name *
</label>
<input
id='nameOfProd'
required
className='dark:white d block w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 text-sm text-gray-900 focus:border-blue-500 focus:ring-blue-500 dark:border-gray-600 dark:placeholder-gray-400 dark:focus:border-blue-500 dark:focus:ring-blue-500'
value={userNameEntry}
onChange={({ target }) => {
setUserNameEntry(target.value);
}}></input>
</Modal>
</div>
</>
);
};
export default AddEdit;