1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-02-09 00:18:00 +01:00

feat: add SSO mappings to groups (#2175)

* feat: add SSO mappings to groups

* add feature flag to conditionally render

* fix EditGroupUsers

* fix: update snap
This commit is contained in:
Nuno Góis 2022-10-13 11:34:47 +01:00 committed by GitHub
parent b1a877e56c
commit a3bf564100
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 243 additions and 65 deletions

View File

@ -22,6 +22,8 @@ export const CreateGroup = () => {
setName,
description,
setDescription,
mappingsSSO,
setMappingsSSO,
users,
setUsers,
getGroupPayload,
@ -92,9 +94,11 @@ export const CreateGroup = () => {
<GroupForm
name={name}
description={description}
mappingsSSO={mappingsSSO}
users={users}
setName={onSetName}
setDescription={setDescription}
setMappingsSSO={setMappingsSSO}
setUsers={setUsers}
errors={errors}
handleSubmit={handleSubmit}

View File

@ -27,13 +27,20 @@ export const EditGroup = () => {
setName,
description,
setDescription,
mappingsSSO,
setMappingsSSO,
users,
setUsers,
getGroupPayload,
clearErrors,
errors,
setErrors,
} = useGroupForm(group?.name, group?.description, group?.users);
} = useGroupForm(
group?.name,
group?.description,
group?.mappingsSSO,
group?.users
);
const { groups } = useGroups();
const { updateGroup, loading } = useGroupApi();
@ -96,9 +103,11 @@ export const EditGroup = () => {
<GroupForm
name={name}
description={description}
mappingsSSO={mappingsSSO}
users={users}
setName={onSetName}
setDescription={setDescription}
setMappingsSSO={setMappingsSSO}
setUsers={setUsers}
errors={errors}
handleSubmit={handleSubmit}

View File

@ -55,6 +55,7 @@ export const EditGroupUsers: FC<IEditGroupUsersProps> = ({
const { users, setUsers, getGroupPayload } = useGroupForm(
group.name,
group.description,
group.mappingsSSO,
group.users
);

View File

@ -6,6 +6,8 @@ import { IGroupUser } from 'interfaces/group';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { GroupFormUsersSelect } from './GroupFormUsersSelect/GroupFormUsersSelect';
import { GroupFormUsersTable } from './GroupFormUsersTable/GroupFormUsersTable';
import { ItemList } from 'component/common/ItemList/ItemList';
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
const StyledForm = styled('form')(() => ({
display: 'flex',
@ -24,6 +26,12 @@ const StyledInput = styled(Input)(({ theme }) => ({
marginBottom: theme.spacing(2),
}));
const StyledItemList = styled(ItemList)(({ theme }) => ({
width: '100%',
maxWidth: theme.spacing(50),
marginBottom: theme.spacing(2),
}));
const StyledGroupFormUsersTableWrapper = styled('div')(({ theme }) => ({
marginBottom: theme.spacing(6),
}));
@ -41,9 +49,11 @@ const StyledCancelButton = styled(Button)(({ theme }) => ({
interface IGroupForm {
name: string;
description: string;
mappingsSSO: string[];
users: IGroupUser[];
setName: (name: string) => void;
setDescription: React.Dispatch<React.SetStateAction<string>>;
setMappingsSSO: React.Dispatch<React.SetStateAction<string[]>>;
setUsers: React.Dispatch<React.SetStateAction<IGroupUser[]>>;
handleSubmit: (e: any) => void;
handleCancel: () => void;
@ -54,71 +64,92 @@ interface IGroupForm {
export const GroupForm: FC<IGroupForm> = ({
name,
description,
mappingsSSO,
users,
setName,
setDescription,
setMappingsSSO,
setUsers,
handleSubmit,
handleCancel,
errors,
mode,
children,
}) => (
<StyledForm onSubmit={handleSubmit}>
<div>
<StyledInputDescription>
What would you like to call your group?
</StyledInputDescription>
<StyledInput
autoFocus
label="Name"
id="group-name"
error={Boolean(errors.name)}
errorText={errors.name}
value={name}
onChange={e => setName(e.target.value)}
data-testid={UG_NAME_ID}
required
/>
<StyledInputDescription>
How would you describe your group?
</StyledInputDescription>
<StyledInput
multiline
rows={4}
label="Description"
placeholder="A short description of the group"
value={description}
onChange={e => setDescription(e.target.value)}
data-testid={UG_DESC_ID}
/>
<ConditionallyRender
condition={mode === 'Create'}
show={
<>
<StyledInputDescription>
Add users to this group
</StyledInputDescription>
<GroupFormUsersSelect
users={users}
setUsers={setUsers}
/>
<StyledGroupFormUsersTableWrapper>
<GroupFormUsersTable
}) => {
const { uiConfig } = useUiConfig();
return (
<StyledForm onSubmit={handleSubmit}>
<div>
<StyledInputDescription>
What would you like to call your group?
</StyledInputDescription>
<StyledInput
autoFocus
label="Name"
id="group-name"
error={Boolean(errors.name)}
errorText={errors.name}
value={name}
onChange={e => setName(e.target.value)}
data-testid={UG_NAME_ID}
required
/>
<StyledInputDescription>
How would you describe your group?
</StyledInputDescription>
<StyledInput
multiline
rows={4}
label="Description"
placeholder="A short description of the group"
value={description}
onChange={e => setDescription(e.target.value)}
data-testid={UG_DESC_ID}
/>
<ConditionallyRender
condition={Boolean(uiConfig.flags.syncSSOGroups)}
show={
<>
<StyledInputDescription>
Is this group associated with SSO groups?
</StyledInputDescription>
<StyledItemList
label="SSO group ID / name"
value={mappingsSSO}
onChange={setMappingsSSO}
/>
</>
}
/>
<ConditionallyRender
condition={mode === 'Create'}
show={
<>
<StyledInputDescription>
Add users to this group
</StyledInputDescription>
<GroupFormUsersSelect
users={users}
setUsers={setUsers}
/>
</StyledGroupFormUsersTableWrapper>
</>
}
/>
</div>
<StyledGroupFormUsersTableWrapper>
<GroupFormUsersTable
users={users}
setUsers={setUsers}
/>
</StyledGroupFormUsersTableWrapper>
</>
}
/>
</div>
<StyledButtonContainer>
{children}
<StyledCancelButton onClick={handleCancel}>
Cancel
</StyledCancelButton>
</StyledButtonContainer>
</StyledForm>
);
<StyledButtonContainer>
{children}
<StyledCancelButton onClick={handleCancel}>
Cancel
</StyledCancelButton>
</StyledButtonContainer>
</StyledForm>
);
};

View File

@ -5,12 +5,14 @@ import { IGroupUser } from 'interfaces/group';
export const useGroupForm = (
initialName = '',
initialDescription = '',
initialMappingsSSO: string[] = [],
initialUsers: IGroupUser[] = []
) => {
const params = useQueryParams();
const groupQueryName = params.get('name');
const [name, setName] = useState(groupQueryName || initialName);
const [description, setDescription] = useState(initialDescription);
const [mappingsSSO, setMappingsSSO] = useState(initialMappingsSSO);
const [users, setUsers] = useState<IGroupUser[]>(initialUsers);
const [errors, setErrors] = useState({});
@ -18,6 +20,7 @@ export const useGroupForm = (
return {
name,
description,
mappingsSSO,
users: users.map(({ id }) => ({
user: { id },
})),
@ -33,6 +36,8 @@ export const useGroupForm = (
setName,
description,
setDescription,
mappingsSSO,
setMappingsSSO,
users,
setUsers,
getGroupPayload,

View File

@ -0,0 +1,75 @@
import { Add } from '@mui/icons-material';
import { Button, Chip, Stack, styled } from '@mui/material';
import Input from 'component/common/Input/Input';
import { useState } from 'react';
const StyledItemListAdd = styled('div')(({ theme }) => ({
display: 'flex',
marginBottom: theme.spacing(1),
'& > div:first-of-type': {
width: '100%',
marginRight: theme.spacing(1),
'& > div:first-of-type': {
width: '100%',
},
},
}));
interface IItemListProps {
label: string;
value: string[];
onChange: React.Dispatch<React.SetStateAction<string[]>>;
}
export const ItemList = ({
label,
value,
onChange,
...props
}: IItemListProps) => {
const [inputValue, setInputValue] = useState('');
const addItem = () => {
onChange(prev => [...prev, inputValue]);
setInputValue('');
};
const removeItem = (value: string) => {
onChange(prev => prev.filter(item => item !== value));
};
return (
<div {...props}>
<StyledItemListAdd>
<Input
label={label}
value={inputValue}
onChange={e => setInputValue(e.target.value)}
onKeyPress={e => {
if (e.key === 'Enter') {
addItem();
}
}}
/>
<Button
startIcon={<Add />}
onClick={addItem}
variant="outlined"
color="primary"
disabled={!inputValue.trim() || value.includes(inputValue)}
>
Add
</Button>
</StyledItemListAdd>
<Stack flexDirection="row" flexWrap={'wrap'} gap={1}>
{value?.map((item, index) => (
<Chip
key={index}
label={item}
onDelete={() => removeItem(item)}
/>
))}
</Stack>
</div>
);
};

View File

@ -4,6 +4,7 @@ import { IGroupUserModel } from 'interfaces/group';
interface ICreateGroupPayload {
name: string;
description: string;
mappingsSSO: string[];
users: IGroupUserModel[];
}

View File

@ -9,6 +9,7 @@ export interface IGroup {
projects: string[];
addedAt?: string;
userCount?: number;
mappingsSSO: string[];
}
export interface IGroupUser extends IUser {

View File

@ -20,7 +20,14 @@ const T = {
ROLES: 'roles',
};
const GROUP_COLUMNS = ['id', 'name', 'description', 'created_at', 'created_by'];
const GROUP_COLUMNS = [
'id',
'name',
'description',
'mappings_sso',
'created_at',
'created_by',
];
const rowToGroup = (row) => {
if (!row) {
@ -30,6 +37,7 @@ const rowToGroup = (row) => {
id: row.id,
name: row.name,
description: row.description,
mappingsSSO: row.mappings_sso,
createdAt: row.created_at,
createdBy: row.created_by,
});
@ -46,9 +54,10 @@ const rowToGroupUser = (row) => {
};
};
const groupToRow = (user: IStoreGroup) => ({
name: user.name,
description: user.description,
const groupToRow = (group: IStoreGroup) => ({
name: group.name,
description: group.description,
mappings_sso: JSON.stringify(group.mappingsSSO),
});
export default class GroupStore implements IGroupStore {
@ -69,10 +78,7 @@ export default class GroupStore implements IGroupStore {
async update(group: IGroupModel): Promise<IGroup> {
const rows = await this.db(T.GROUPS)
.where({ id: group.id })
.update({
name: group.name,
description: group.description,
})
.update(groupToRow(group))
.returning(GROUP_COLUMNS);
return rowToGroup(rows[0]);

View File

@ -17,6 +17,12 @@ export const groupSchema = {
description: {
type: 'string',
},
mappingsSSO: {
type: 'array',
items: {
type: 'string',
},
},
createdBy: {
type: 'string',
nullable: true,

View File

@ -5,6 +5,7 @@ export interface IGroup {
id?: number;
name: string;
description?: string;
mappingsSSO?: string[];
createdAt?: Date;
userCount?: number;
createdBy?: string;
@ -57,7 +58,16 @@ export default class Group implements IGroup {
description: string;
constructor({ id, name, description, createdBy, createdAt }: IGroup) {
mappingsSSO: string[];
constructor({
id,
name,
description,
mappingsSSO,
createdBy,
createdAt,
}: IGroup) {
if (!id) {
throw new TypeError('Id is required');
}
@ -67,6 +77,7 @@ export default class Group implements IGroup {
this.id = id;
this.name = name;
this.description = description;
this.mappingsSSO = mappingsSSO;
this.createdBy = createdBy;
this.createdAt = createdAt;
}

View File

@ -11,6 +11,7 @@ import {
export interface IStoreGroup {
name: string;
description?: string;
mappingsSSO?: string[];
}
export interface IGroupStore extends Store<IGroup, number> {

View File

@ -0,0 +1,21 @@
'use strict';
exports.up = function (db, cb) {
db.runSql(
`
ALTER TABLE groups
ADD COLUMN IF NOT EXISTS mappings_sso jsonb DEFAULT '[]'::jsonb
`,
cb,
);
};
exports.down = function (db, cb) {
db.runSql(
`
ALTER TABLE groups
DROP COLUMN IF EXISTS mappings_sso;
`,
cb,
);
};

View File

@ -1390,6 +1390,12 @@ exports[`should serve the OpenAPI spec 1`] = `
"id": {
"type": "number",
},
"mappingsSSO": {
"items": {
"type": "string",
},
"type": "array",
},
"name": {
"type": "string",
},