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, setName,
description, description,
setDescription, setDescription,
mappingsSSO,
setMappingsSSO,
users, users,
setUsers, setUsers,
getGroupPayload, getGroupPayload,
@ -92,9 +94,11 @@ export const CreateGroup = () => {
<GroupForm <GroupForm
name={name} name={name}
description={description} description={description}
mappingsSSO={mappingsSSO}
users={users} users={users}
setName={onSetName} setName={onSetName}
setDescription={setDescription} setDescription={setDescription}
setMappingsSSO={setMappingsSSO}
setUsers={setUsers} setUsers={setUsers}
errors={errors} errors={errors}
handleSubmit={handleSubmit} handleSubmit={handleSubmit}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -11,6 +11,7 @@ import {
export interface IStoreGroup { export interface IStoreGroup {
name: string; name: string;
description?: string; description?: string;
mappingsSSO?: string[];
} }
export interface IGroupStore extends Store<IGroup, number> { 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": { "id": {
"type": "number", "type": "number",
}, },
"mappingsSSO": {
"items": {
"type": "string",
},
"type": "array",
},
"name": { "name": {
"type": "string", "type": "string",
}, },