* along with this program; if not, write to the Free Software Foundation,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
-import classNames from 'classnames';
import * as React from 'react';
import Checkbox from '../../../../components/controls/Checkbox';
import Modal from '../../../../components/controls/Modal';
-import { SubmitButton } from '../../../../components/controls/buttons';
+import { DeleteButton, SubmitButton } from '../../../../components/controls/buttons';
import PermissionHeader from '../../../../components/permissions/PermissionHeader';
+import { Alert } from '../../../../components/ui/Alert';
import Spinner from '../../../../components/ui/Spinner';
import { translate } from '../../../../helpers/l10n';
import {
import { GitHubMapping } from '../../../../types/provisioning';
interface Props {
- readonly mapping: GitHubMapping[] | null;
- readonly setMapping: React.Dispatch<React.SetStateAction<GitHubMapping[] | null>>;
- readonly onClose: () => void;
+ mapping: GitHubMapping[] | null;
+ setMapping: React.Dispatch<React.SetStateAction<GitHubMapping[] | null>>;
+ onClose: () => void;
}
-export default function GitHubMappingModal({ mapping, setMapping, onClose }: Props) {
+interface PermissionCellProps {
+ mapping: GitHubMapping;
+ setMapping: React.Dispatch<React.SetStateAction<GitHubMapping[] | null>>;
+ list?: GitHubMapping[];
+}
+
+const DEFAULT_CUSTOM_ROLE_PERMISSIONS: GitHubMapping['permissions'] = {
+ user: false,
+ codeviewer: false,
+ issueadmin: false,
+ securityhotspotadmin: false,
+ admin: false,
+ scan: false,
+};
+
+function PermissionRow(props: Readonly<PermissionCellProps>) {
+ const { mapping, list } = props;
+
+ return (
+ <tr>
+ <th scope="row" className="nowrap text-middle sw-pl-[10px]">
+ <div className="sw-flex sw-max-w-[150px] sw-items-center">
+ <b
+ className={mapping.isBaseRole ? 'sw-capitalize' : 'sw-truncate'}
+ title={mapping.roleName}
+ >
+ {mapping.roleName}
+ </b>
+ {!mapping.isBaseRole && (
+ <DeleteButton
+ onClick={() => {
+ props.setMapping(list?.filter((r) => r.roleName !== mapping.roleName) ?? null);
+ }}
+ />
+ )}
+ </div>
+ </th>
+ {Object.entries(mapping.permissions).map(([key, value]) => (
+ <td key={key} className="permission-column text-center text-middle">
+ <Checkbox
+ checked={value}
+ onCheck={(newValue) =>
+ props.setMapping(
+ list?.map((item) =>
+ item.id === mapping.id
+ ? { ...item, permissions: { ...item.permissions, [key]: newValue } }
+ : item,
+ ) ?? null,
+ )
+ }
+ />
+ </td>
+ ))}
+ </tr>
+ );
+}
+
+export default function GitHubMappingModal({ mapping, setMapping, onClose }: Readonly<Props>) {
const { data: roles, isLoading } = useGithubRolesMappingQuery();
const permissions = convertToPermissionDefinitions(
PERMISSIONS_ORDER_FOR_PROJECT_TEMPLATE,
'projects_role',
);
+ const [customRoleInput, setCustomRoleInput] = React.useState('');
+ const [customRoleError, setCustomRoleError] = React.useState(false);
const header = translate(
'settings.authentication.github.configuration.roles_mapping.dialog.title',
);
+ const list = mapping ?? roles;
+
+ const validateAndAddCustomRole = (e: React.FormEvent) => {
+ e.preventDefault();
+ const value = customRoleInput.trim();
+ if (
+ !list?.some((el) =>
+ el.isBaseRole ? el.roleName.toLowerCase() === value.toLowerCase() : el.roleName === value,
+ )
+ ) {
+ setMapping([
+ {
+ id: customRoleInput,
+ roleName: customRoleInput,
+ permissions: { ...DEFAULT_CUSTOM_ROLE_PERMISSIONS },
+ },
+ ...(list ?? []),
+ ]);
+ setCustomRoleInput('');
+ } else {
+ setCustomRoleError(true);
+ }
+ };
+
return (
<Modal contentLabel={header} onRequestClose={onClose} shouldCloseOnEsc size="medium">
<div className="modal-head">
<div className="modal-body modal-container sw-p-0">
<table className="data zebra permissions-table">
<thead>
- <tr>
- <th scope="col" className="nowrap bordered-bottom sw-pl-[10px] sw-align-middle">
+ <tr className="sw-sticky sw-top-0 sw-bg-white sw-z-filterbar">
+ <th
+ scope="col"
+ className="nowrap bordered-bottom sw-pl-[10px] sw-align-middle sw-w-[150px]"
+ >
<b>
{translate(
'settings.authentication.github.configuration.roles_mapping.dialog.roles_column',
</tr>
</thead>
<tbody>
- {(mapping ?? roles)?.map(({ id, roleName, permissions }) => (
- <tr key={id}>
- <th scope="row" className="nowrap text-middle sw-pl-[10px]">
- <b>{roleName}</b>
- </th>
- {Object.entries(permissions).map(([key, value]) => (
- <td key={key} className={classNames('permission-column text-center text-middle')}>
- <Checkbox
- checked={value}
- onCheck={(newValue) =>
- setMapping(
- (mapping ?? roles)?.map((item) =>
- item.id === id
- ? { ...item, permissions: { ...item.permissions, [key]: newValue } }
- : item,
- ) ?? null,
- )
- }
- />
- </td>
- ))}
- </tr>
- ))}
+ {list
+ ?.filter((r) => r.isBaseRole)
+ .map((mapping) => (
+ <PermissionRow
+ key={mapping.id}
+ mapping={mapping}
+ setMapping={setMapping}
+ list={list}
+ />
+ ))}
+ <tr>
+ <td colSpan={7} className="sw-pt-5 sw-border-t">
+ <Alert variant="info">
+ {translate(
+ 'settings.authentication.github.configuration.roles_mapping.dialog.custom_roles_description',
+ )}
+ </Alert>
+ <form
+ className="sw-flex sw-h-9 sw-items-center"
+ onSubmit={validateAndAddCustomRole}
+ >
+ <label htmlFor="custom-role-input">
+ {translate(
+ 'settings.authentication.github.configuration.roles_mapping.dialog.add_custom_role',
+ )}
+ </label>
+ <input
+ className="sw-w-[300px] sw-mx-2"
+ id="custom-role-input"
+ maxLength={4000}
+ value={customRoleInput}
+ onChange={(event) => {
+ setCustomRoleError(false);
+ setCustomRoleInput(event.currentTarget.value);
+ }}
+ type="text"
+ />
+ <SubmitButton disabled={!customRoleInput.trim() || customRoleError}>
+ {translate('add_verb')}
+ </SubmitButton>
+ <Alert variant="error" className="sw-inline-block sw-ml-2 sw-mb-0">
+ {customRoleError &&
+ translate(
+ 'settings.authentication.github.configuration.roles_mapping.role_exists',
+ )}
+ </Alert>
+ </form>
+ </td>
+ </tr>
+ {list
+ ?.filter((r) => !r.isBaseRole)
+ .map((mapping) => (
+ <PermissionRow
+ key={mapping.id}
+ mapping={mapping}
+ setMapping={setMapping}
+ list={list}
+ />
+ ))}
</tbody>
</table>
<Spinner loading={isLoading} />
)}
</ConfirmModal>
)}
- {showMappingModal && (
- <GitHubMappingModal
- mapping={rolesMapping}
- setMapping={setRolesMapping}
- onClose={() => setShowMappingModal(false)}
- />
- )}
</form>
+ {showMappingModal && (
+ <GitHubMappingModal
+ mapping={rolesMapping}
+ setMapping={setRolesMapping}
+ onClose={() => setShowMappingModal(false)}
+ />
+ )}
</div>
</>
)}
mappingRow: byRole('dialog', {
name: 'settings.authentication.github.configuration.roles_mapping.dialog.title',
}).byRole('row'),
+ getMappingRowByRole: (text: string) =>
+ ui.github.mappingRow.getAll().find((row) => within(row).queryByText(text) !== null),
mappingCheckbox: byRole('checkbox'),
mappingDialogClose: byRole('dialog', {
name: 'settings.authentication.github.configuration.roles_mapping.dialog.title',
expect(await github.editMappingButton.find()).toBeInTheDocument();
await user.click(github.editMappingButton.get());
- expect(await github.mappingRow.findAll()).toHaveLength(6);
- expect(github.mappingRow.getAt(1)).toHaveTextContent('read');
- expect(github.mappingRow.getAt(2)).toHaveTextContent('triage');
- expect(github.mappingRow.getAt(3)).toHaveTextContent('write');
- expect(github.mappingRow.getAt(4)).toHaveTextContent('maintain');
- expect(github.mappingRow.getAt(5)).toHaveTextContent('admin');
+ const rows = (await github.mappingRow.findAll()).filter(
+ (row) => within(row).queryAllByRole('checkbox').length > 0,
+ );
+
+ expect(rows).toHaveLength(5);
+
+ expect(rows[0]).toHaveTextContent('read');
+ expect(rows[1]).toHaveTextContent('triage');
+ expect(rows[2]).toHaveTextContent('write');
+ expect(rows[3]).toHaveTextContent('maintain');
+ expect(rows[4]).toHaveTextContent('admin');
});
it('should apply new mapping and new provisioning type at the same time', async () => {
expect(await github.editMappingButton.find()).toBeInTheDocument();
await user.click(github.editMappingButton.get());
- expect(await github.mappingRow.findAll()).toHaveLength(6);
+ expect(await github.mappingRow.findAll()).toHaveLength(7);
- let rowOneCheckboxes = github.mappingCheckbox.getAll(github.mappingRow.getAt(1));
- let rowFiveCheckboxes = github.mappingCheckbox.getAll(github.mappingRow.getAt(5));
+ let readCheckboxes = github.mappingCheckbox.getAll(github.getMappingRowByRole('read'));
+ let adminCheckboxes = github.mappingCheckbox.getAll(github.getMappingRowByRole('admin'));
- expect(rowOneCheckboxes[0]).toBeChecked();
- expect(rowOneCheckboxes[5]).not.toBeChecked();
- expect(rowFiveCheckboxes[5]).toBeChecked();
+ expect(readCheckboxes[0]).toBeChecked();
+ expect(readCheckboxes[5]).not.toBeChecked();
+ expect(adminCheckboxes[5]).toBeChecked();
- await user.click(rowOneCheckboxes[0]);
- await user.click(rowOneCheckboxes[5]);
- await user.click(rowFiveCheckboxes[5]);
+ await user.click(readCheckboxes[0]);
+ await user.click(readCheckboxes[5]);
+ await user.click(adminCheckboxes[5]);
await user.click(github.mappingDialogClose.get());
await user.click(github.saveGithubProvisioning.get());
await user.click(github.githubProvisioningButton.get());
await user.click(github.editMappingButton.get());
- rowOneCheckboxes = github.mappingCheckbox.getAll(github.mappingRow.getAt(1));
- rowFiveCheckboxes = github.mappingCheckbox.getAll(github.mappingRow.getAt(5));
+ readCheckboxes = github.mappingCheckbox.getAll(github.getMappingRowByRole('read'));
+ adminCheckboxes = github.mappingCheckbox.getAll(github.getMappingRowByRole('admin'));
- expect(rowOneCheckboxes[0]).not.toBeChecked();
- expect(rowOneCheckboxes[5]).toBeChecked();
- expect(rowFiveCheckboxes[5]).not.toBeChecked();
+ expect(readCheckboxes[0]).not.toBeChecked();
+ expect(readCheckboxes[5]).toBeChecked();
+ expect(adminCheckboxes[5]).not.toBeChecked();
await user.click(github.mappingDialogClose.get());
});
expect(await github.saveGithubProvisioning.find()).toBeDisabled();
await user.click(github.editMappingButton.get());
- expect(await github.mappingRow.findAll()).toHaveLength(6);
+ expect(await github.mappingRow.findAll()).toHaveLength(7);
- let rowOneCheckbox = github.mappingCheckbox.getAll(github.mappingRow.getAt(1))[0];
+ let readCheckboxes = github.mappingCheckbox.getAll(github.getMappingRowByRole('read'))[0];
- expect(rowOneCheckbox).toBeChecked();
+ expect(readCheckboxes).toBeChecked();
- await user.click(rowOneCheckbox);
+ await user.click(readCheckboxes);
await user.click(github.mappingDialogClose.get());
expect(await github.saveGithubProvisioning.find()).toBeEnabled();
await user.click(github.githubProvisioningButton.get());
await user.click(github.editMappingButton.get());
- rowOneCheckbox = github.mappingCheckbox.getAll(github.mappingRow.getAt(1))[0];
+ readCheckboxes = github.mappingCheckbox.getAll(github.getMappingRowByRole('read'))[0];
- expect(rowOneCheckbox).not.toBeChecked();
+ expect(readCheckboxes).not.toBeChecked();
await user.click(github.mappingDialogClose.get());
});
});
import 'axios';
+type IfEquals<X, Y, A = X, B = never> = (<T>() => T extends X ? 1 : 2) extends <T>() => T extends Y
+ ? 1
+ : 2
+ ? A
+ : B;
+
+type WritableKeys<T> = {
+ [P in keyof T]-?: IfEquals<{ [Q in P]: T[P] }, { -readonly [Q in P]: T[P] }, P>;
+}[keyof T];
+
+type OmitReadonly<T> = Pick<T, WritableKeys<T>>;
+
declare module 'axios' {
export interface AxiosInstance {
get<T = any>(url: string, config?: AxiosRequestConfig): Promise<T>;
delete<T = void>(url: string, config?: AxiosRequestConfig): Promise<T>;
post<T = any, D = any>(url: string, data?: D, config?: AxiosRequestConfig<D>): Promise<T>;
- patch<T = any, D = any>(url: string, data?: D, config?: AxiosRequestConfig<D>): Promise<T>;
+ patch<T = any, D = Partial<OmitReadonly<T>>>(
+ url: string,
+ data?: D,
+ config?: AxiosRequestConfig<D>,
+ ): Promise<T>;
defaults: Omit<AxiosDefaults, 'headers'> & {
headers: HeadersDefaults & {
}
export interface GitHubMapping {
- id: string;
- roleName: string;
+ readonly id: string;
+ readonly roleName: string;
+ readonly isBaseRole?: boolean;
permissions: {
user: boolean;
codeviewer: boolean;
settings.authentication.github.configuration.roles_mapping.button_label=Edit mapping
settings.authentication.github.configuration.roles_mapping.dialog.title=GitHub Roles Mapping
settings.authentication.github.configuration.roles_mapping.dialog.roles_column=Roles
+settings.authentication.github.configuration.roles_mapping.dialog.add_custom_role=Add custom role:
+settings.authentication.github.configuration.roles_mapping.role_exists=Role already exists
+settings.authentication.github.configuration.roles_mapping.dialog.custom_roles_description=Define a custom GitHub role to create a mapping. If the custom role name matches an existing role in any of your organizations, this mapping will apply to all occurrences of that role. If the existing custom role doesn't match any entries in this mapping, it will gracefully fall back to its base role.
settings.authentication.github.configuration.unsaved_changes=You have unsaved changes.
# SAML