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

refactor: roles - make better plan assumptions (#4113)

https://linear.app/unleash/issue/2-1171/refactor-custom-root-roles-with-correct-plan-assumptions

This cleans up the hotfix `RoleSelect2` component and makes `RoleSelect`
take in a `roles` prop from the parent component.

This also simplifies the role hooks again to assume Enterprise plan by
default. This means, however, that we must ensure that we only call
these hooks in Enterprise features or, if we do call them in other
plans, that we provide a graceful fallback for non-Enterprise.
Non-Enterprise instances do not have this endpoint, and so they are
currently grabbing role information from e.g. `useUsers` and
`useServiceAccounts`.

I'm not sure how I feel about this. Roles are an overarching concept of
Unleash. To me, having to be extremely conscious about the exact
scenario in which you're using such a hook feels like a trap, instead of
"I need roles, so I'll grab the `useRoles` hook and not think much about
it". I also don't like the way `roles` are currently tied to the users,
service accounts, project access, (...) instead of being its own thing.

This could be solved by a `RoleController` exposing the GET endpoints in
OSS, since all of the logic we need for this use-case lives there
anyways. This would then be overridden with the Enterprise-specific
controller when wrapped. This way we could assume the endpoint is always
there, no matter the plan.
This is just an idea and not something I explored in the PR. For now I'm
just focusing on leaving this feature in a sane state.

Tested this manually on `Pro` and `Enterprise` and I believe everything
is acting the way we intend, but would love some extra eyes.
This commit is contained in:
Nuno Góis 2023-06-28 16:00:14 +01:00 committed by GitHub
parent bc68b5d265
commit 0b3ed79ecc
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 54 additions and 169 deletions

View File

@ -194,6 +194,7 @@ export const GroupForm: FC<IGroupForm> = ({
<StyledAutocompleteWrapper> <StyledAutocompleteWrapper>
<RoleSelect <RoleSelect
data-testid="GROUP_ROOT_ROLE" data-testid="GROUP_ROOT_ROLE"
roles={roles}
value={roleIdToRole(rootRole)} value={roleIdToRole(rootRole)}
setValue={role => setValue={role =>
setRootRole(role?.id || null) setRootRole(role?.id || null)

View File

@ -306,6 +306,7 @@ export const ServiceAccountModal = ({
What is your service account allowed to do? What is your service account allowed to do?
</StyledInputDescription> </StyledInputDescription>
<StyledRoleSelect <StyledRoleSelect
roles={roles}
value={rootRole} value={rootRole}
setValue={setRootRole} setValue={setRootRole}
required required

View File

@ -6,6 +6,7 @@ import { EDIT } from 'constants/misc';
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig'; import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
import { RoleSelect } from 'component/common/RoleSelect/RoleSelect'; import { RoleSelect } from 'component/common/RoleSelect/RoleSelect';
import { IRole } from 'interfaces/role'; import { IRole } from 'interfaces/role';
import { useUsers } from 'hooks/api/getters/useUsers/useUsers';
const StyledForm = styled('form')(() => ({ const StyledForm = styled('form')(() => ({
display: 'flex', display: 'flex',
@ -78,6 +79,7 @@ const UserForm: React.FC<IUserForm> = ({
mode, mode,
}) => { }) => {
const { uiConfig } = useUiConfig(); const { uiConfig } = useUiConfig();
const { roles } = useUsers();
return ( return (
<StyledForm onSubmit={handleSubmit}> <StyledForm onSubmit={handleSubmit}>
@ -106,7 +108,12 @@ const UserForm: React.FC<IUserForm> = ({
<StyledRoleSubtitle variant="subtitle1" data-loading> <StyledRoleSubtitle variant="subtitle1" data-loading>
What is your team member allowed to do? What is your team member allowed to do?
</StyledRoleSubtitle> </StyledRoleSubtitle>
<RoleSelect value={rootRole} setValue={setRootRole} required /> <RoleSelect
roles={roles}
value={rootRole}
setValue={setRootRole}
required
/>
<ConditionallyRender <ConditionallyRender
condition={mode !== EDIT && Boolean(uiConfig?.emailEnabled)} condition={mode !== EDIT && Boolean(uiConfig?.emailEnabled)}
show={ show={

View File

@ -2,7 +2,6 @@ import { useEffect, useState } from 'react';
import { useUsers } from 'hooks/api/getters/useUsers/useUsers'; import { useUsers } from 'hooks/api/getters/useUsers/useUsers';
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig'; import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
import { IRole } from 'interfaces/role'; import { IRole } from 'interfaces/role';
import { useRoles } from 'hooks/api/getters/useRoles/useRoles';
const useCreateUserForm = ( const useCreateUserForm = (
initialName = '', initialName = '',
@ -10,7 +9,7 @@ const useCreateUserForm = (
initialRootRole = null initialRootRole = null
) => { ) => {
const { uiConfig } = useUiConfig(); const { uiConfig } = useUiConfig();
const { roles } = useRoles(); const { users, roles } = useUsers();
const [name, setName] = useState(initialName); const [name, setName] = useState(initialName);
const [email, setEmail] = useState(initialEmail); const [email, setEmail] = useState(initialEmail);
const [sendEmail, setSendEmail] = useState(false); const [sendEmail, setSendEmail] = useState(false);
@ -19,8 +18,6 @@ const useCreateUserForm = (
); );
const [errors, setErrors] = useState({}); const [errors, setErrors] = useState({});
const { users } = useUsers();
useEffect(() => { useEffect(() => {
setName(initialName); setName(initialName);
}, [initialName]); }, [initialName]);

View File

@ -6,12 +6,21 @@ import { RoleDescription } from 'component/common/RoleDescription/RoleDescriptio
interface IRoleBadgeProps { interface IRoleBadgeProps {
roleId: number; roleId: number;
children?: string;
} }
export const RoleBadge = ({ roleId }: IRoleBadgeProps) => { export const RoleBadge = ({ roleId, children }: IRoleBadgeProps) => {
const { role } = useRole(roleId.toString()); const { role } = useRole(roleId.toString());
if (!role) return null; if (!role) {
if (children)
return (
<Badge color="success" icon={<UserIcon />}>
{children}
</Badge>
);
return null;
}
return ( return (
<HtmlTooltip title={<RoleDescription roleId={roleId} tooltip />} arrow> <HtmlTooltip title={<RoleDescription roleId={roleId} tooltip />} arrow>

View File

@ -4,11 +4,9 @@ import {
TextField, TextField,
styled, styled,
} from '@mui/material'; } from '@mui/material';
import { useRoles } from 'hooks/api/getters/useRoles/useRoles'; import { IRole } from 'interfaces/role';
import { IRole, PredefinedRoleType } from 'interfaces/role';
import { RoleDescription } from '../RoleDescription/RoleDescription'; import { RoleDescription } from '../RoleDescription/RoleDescription';
import { ConditionallyRender } from '../ConditionallyRender/ConditionallyRender'; import { ConditionallyRender } from '../ConditionallyRender/ConditionallyRender';
import { ROOT_ROLE_TYPE } from '@server/util/constants';
const StyledRoleOption = styled('div')(({ theme }) => ({ const StyledRoleOption = styled('div')(({ theme }) => ({
display: 'flex', display: 'flex',
@ -21,23 +19,19 @@ const StyledRoleOption = styled('div')(({ theme }) => ({
interface IRoleSelectProps interface IRoleSelectProps
extends Partial<AutocompleteProps<IRole, false, false, false>> { extends Partial<AutocompleteProps<IRole, false, false, false>> {
type?: PredefinedRoleType; roles: IRole[];
value: IRole | null; value: IRole | null;
setValue: (role: IRole | null) => void; setValue: (role: IRole | null) => void;
required?: boolean; required?: boolean;
} }
export const RoleSelect = ({ export const RoleSelect = ({
type = ROOT_ROLE_TYPE, roles,
value, value,
setValue, setValue,
required, required,
...rest ...rest
}: IRoleSelectProps) => { }: IRoleSelectProps) => {
const { roles: rootRoles, projectRoles } = useRoles();
const roles = type === ROOT_ROLE_TYPE ? rootRoles : projectRoles;
const renderRoleOption = ( const renderRoleOption = (
props: React.HTMLAttributes<HTMLLIElement>, props: React.HTMLAttributes<HTMLLIElement>,
option: IRole option: IRole

View File

@ -1,71 +0,0 @@
import {
Autocomplete,
AutocompleteProps,
TextField,
styled,
} from '@mui/material';
import { useRoles } from 'hooks/api/getters/useRoles/useRoles';
import { IRole, PredefinedRoleType } from 'interfaces/role';
import { RoleDescription } from '../RoleDescription/RoleDescription';
import { ConditionallyRender } from '../ConditionallyRender/ConditionallyRender';
const StyledRoleOption = styled('div')(({ theme }) => ({
display: 'flex',
flexDirection: 'column',
'& > span:last-of-type': {
fontSize: theme.fontSizes.smallerBody,
color: theme.palette.text.secondary,
},
}));
interface IRoleSelectProps
extends Partial<AutocompleteProps<IRole, false, false, false>> {
value: IRole | null;
setValue: (role: IRole | null) => void;
roles: IRole[];
required?: boolean;
}
export const RoleSelect = ({
value,
setValue,
required,
roles,
...rest
}: IRoleSelectProps) => {
const renderRoleOption = (
props: React.HTMLAttributes<HTMLLIElement>,
option: IRole
) => (
<li {...props}>
<StyledRoleOption>
<span>{option.name}</span>
<span>{option.description}</span>
</StyledRoleOption>
</li>
);
return (
<>
<Autocomplete
openOnFocus
size="small"
value={value}
onChange={(_, role) => setValue(role || null)}
options={roles}
renderOption={renderRoleOption}
getOptionLabel={option => option.name}
renderInput={params => (
<TextField {...params} label="Role" required={required} />
)}
{...rest}
/>
<ConditionallyRender
condition={Boolean(value)}
show={() => (
<RoleDescription sx={{ marginTop: 1 }} roleId={value!.id} />
)}
/>
</>
);
};

View File

@ -10,9 +10,9 @@ interface IRoleCellProps {
} }
export const RoleCell: VFC<IRoleCellProps> = ({ roleId, value }) => { export const RoleCell: VFC<IRoleCellProps> = ({ roleId, value }) => {
const { isEnterprise, uiConfig } = useUiConfig(); const { isEnterprise } = useUiConfig();
if (isEnterprise() && uiConfig.flags.customRootRoles) { if (isEnterprise()) {
return ( return (
<TextCell> <TextCell>
<TooltipLink <TooltipLink

View File

@ -35,8 +35,7 @@ import {
} from 'utils/testIds'; } from 'utils/testIds';
import { caseInsensitiveSearch } from 'utils/search'; import { caseInsensitiveSearch } from 'utils/search';
import { IServiceAccount } from 'interfaces/service-account'; import { IServiceAccount } from 'interfaces/service-account';
import { RoleSelect } from 'component/common/RoleSelect/RoleSelect2'; import { RoleSelect } from 'component/common/RoleSelect/RoleSelect';
import { PROJECT_ROLE_TYPE } from '@server/util/constants';
const StyledForm = styled('form')(() => ({ const StyledForm = styled('form')(() => ({
display: 'flex', display: 'flex',

View File

@ -141,7 +141,9 @@ export const ProfileTab = ({ user }: IProfileTabProps) => {
<Typography variant="body2"> <Typography variant="body2">
Your root role Your root role
</Typography> </Typography>
<RoleBadge roleId={profile?.rootRole.id!} /> <RoleBadge roleId={profile?.rootRole.id!}>
{profile?.rootRole.name}
</RoleBadge>
</> </>
)} )}
/> />

View File

@ -2,7 +2,7 @@ import { SWRConfiguration } from 'swr';
import { useMemo } from 'react'; import { useMemo } from 'react';
import { formatApiPath } from 'utils/formatPath'; import { formatApiPath } from 'utils/formatPath';
import handleErrorResponses from '../httpErrorResponseHandler'; import handleErrorResponses from '../httpErrorResponseHandler';
import { IRole, IRoleWithPermissions } from 'interfaces/role'; import { IRoleWithPermissions } from 'interfaces/role';
import useUiConfig from '../useUiConfig/useUiConfig'; import useUiConfig from '../useUiConfig/useUiConfig';
import { useConditionalSWR } from '../useConditionalSWR/useConditionalSWR'; import { useConditionalSWR } from '../useConditionalSWR/useConditionalSWR';
@ -27,40 +27,15 @@ export const useRole = (
options options
); );
const { return useMemo(
data: ossData, () => ({
error: ossError,
mutate: ossMutate,
} = useConditionalSWR(
Boolean(id) && !isEnterprise(),
{ rootRoles: [] },
formatApiPath(`api/admin/user-admin`),
fetcher,
options
);
return useMemo(() => {
if (!isEnterprise()) {
return {
role: {
...((ossData?.rootRoles ?? []) as IRole[]).find(
({ id: rId }) => rId === +id!
),
permissions: [],
} as IRoleWithPermissions,
loading: !ossError && !ossData,
refetch: () => ossMutate(),
error: ossError,
};
} else {
return {
role: data as IRoleWithPermissions, role: data as IRoleWithPermissions,
loading: !error && !data, loading: !error && !data,
refetch: () => mutate(), refetch: () => mutate(),
error, error,
}; }),
} [data, error, mutate]
}, [data, error, mutate, ossData, ossError, ossMutate]); );
}; };
const fetcher = (path: string) => { const fetcher = (path: string) => {

View File

@ -6,7 +6,6 @@ import { useConditionalSWR } from '../useConditionalSWR/useConditionalSWR';
import useUiConfig from '../useUiConfig/useUiConfig'; import useUiConfig from '../useUiConfig/useUiConfig';
import { import {
PROJECT_ROLE_TYPES, PROJECT_ROLE_TYPES,
ROOT_ROLE_TYPE,
ROOT_ROLE_TYPES, ROOT_ROLE_TYPES,
PREDEFINED_ROLE_TYPES, PREDEFINED_ROLE_TYPES,
} from '@server/util/constants'; } from '@server/util/constants';
@ -20,7 +19,7 @@ interface IUseRolesOutput {
} }
export const useRoles = (): IUseRolesOutput => { export const useRoles = (): IUseRolesOutput => {
const { isEnterprise, uiConfig } = useUiConfig(); const { isEnterprise } = useUiConfig();
const { data, error, mutate } = useConditionalSWR( const { data, error, mutate } = useConditionalSWR(
isEnterprise(), isEnterprise(),
@ -29,48 +28,20 @@ export const useRoles = (): IUseRolesOutput => {
fetcher fetcher
); );
const { return useMemo(
data: ossData, () => ({
error: ossError,
mutate: ossMutate,
} = useConditionalSWR(
!isEnterprise(),
{ rootRoles: [] },
formatApiPath(`api/admin/user-admin`),
fetcher
);
return useMemo(() => {
if (!isEnterprise()) {
return {
roles: ossData?.rootRoles
.filter(({ type }: IRole) => type === ROOT_ROLE_TYPE)
.sort(sortRoles) as IRole[],
projectRoles: [],
loading: !ossError && !ossData,
refetch: () => ossMutate(),
error: ossError,
};
} else {
return {
roles: (data?.roles roles: (data?.roles
.filter(({ type }: IRole) => .filter(({ type }: IRole) => ROOT_ROLE_TYPES.includes(type))
uiConfig.flags.customRootRoles
? ROOT_ROLE_TYPES.includes(type)
: type === ROOT_ROLE_TYPE
)
.sort(sortRoles) ?? []) as IRole[], .sort(sortRoles) ?? []) as IRole[],
projectRoles: (data?.roles projectRoles: (data?.roles
.filter(({ type }: IRole) => .filter(({ type }: IRole) => PROJECT_ROLE_TYPES.includes(type))
PROJECT_ROLE_TYPES.includes(type)
)
.sort(sortRoles) ?? []) as IRole[], .sort(sortRoles) ?? []) as IRole[],
loading: !error && !data, loading: !error && !data,
refetch: () => mutate(), refetch: () => mutate(),
error, error,
}; }),
} [data, error, mutate]
}, [data, error, mutate, ossData, ossError, ossMutate]); );
}; };
const fetcher = (path: string) => { const fetcher = (path: string) => {