Merge remote-tracking branch 'origin/main' into ds-apiserver-with-configs
This commit is contained in:
@@ -27,11 +27,26 @@ export interface TabProps extends HTMLProps<HTMLElement> {
|
||||
suffix?: NavModelItem['tabSuffix'];
|
||||
truncate?: boolean;
|
||||
tooltip?: string;
|
||||
/** When true, the tab will be disabled and not clickable */
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export const Tab = React.forwardRef<HTMLElement, TabProps>(
|
||||
(
|
||||
{ label, active, icon, onChangeTab, counter, suffix: Suffix, className, href, truncate, tooltip, ...otherProps },
|
||||
{
|
||||
label,
|
||||
active,
|
||||
icon,
|
||||
onChangeTab,
|
||||
counter,
|
||||
suffix: Suffix,
|
||||
className,
|
||||
href,
|
||||
truncate,
|
||||
tooltip,
|
||||
disabled,
|
||||
...otherProps
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const tabsStyles = useStyles2(getStyles);
|
||||
@@ -50,16 +65,19 @@ export const Tab = React.forwardRef<HTMLElement, TabProps>(
|
||||
clearStyles,
|
||||
tabsStyles.link,
|
||||
active ? tabsStyles.activeStyle : tabsStyles.notActive,
|
||||
truncate && tabsStyles.linkTruncate
|
||||
truncate && tabsStyles.linkTruncate,
|
||||
disabled && tabsStyles.disabled
|
||||
);
|
||||
|
||||
const commonProps = {
|
||||
className: linkClass,
|
||||
'data-testid': selectors.components.Tab.title(label),
|
||||
...otherProps,
|
||||
onClick: onChangeTab,
|
||||
onClick: disabled ? undefined : onChangeTab,
|
||||
role: 'tab',
|
||||
'aria-selected': active,
|
||||
'aria-disabled': disabled,
|
||||
tabIndex: disabled ? -1 : undefined,
|
||||
title: !!tooltip ? undefined : otherProps.title, // If tooltip is provided, don't set the title on the link or button, it looks weird
|
||||
};
|
||||
|
||||
@@ -70,7 +88,7 @@ export const Tab = React.forwardRef<HTMLElement, TabProps>(
|
||||
<div className={cx(tabsStyles.item, truncate && tabsStyles.itemTruncate, className)}>
|
||||
<a
|
||||
{...commonProps}
|
||||
href={href}
|
||||
href={disabled ? undefined : href}
|
||||
// don't think we can avoid the type assertion here :(
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
ref={ref as React.ForwardedRef<HTMLAnchorElement>}
|
||||
@@ -169,5 +187,17 @@ const getStyles = (theme: GrafanaTheme2) => {
|
||||
suffix: css({
|
||||
marginLeft: theme.spacing(1),
|
||||
}),
|
||||
disabled: css({
|
||||
color: theme.colors.text.disabled,
|
||||
cursor: 'not-allowed',
|
||||
|
||||
'&:hover, &:focus': {
|
||||
color: theme.colors.text.disabled,
|
||||
|
||||
'&::before': {
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
},
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -58,4 +58,37 @@ Counter.args = {
|
||||
value: 10,
|
||||
};
|
||||
|
||||
export const WithDisabled: StoryFn = () => {
|
||||
const [state, updateState] = useState([
|
||||
{ label: 'Enabled Tab', key: 'first', active: true },
|
||||
{ label: 'Disabled Tab', key: 'second', active: false, disabled: true },
|
||||
{ label: 'Another Tab', key: 'third', active: false },
|
||||
]);
|
||||
|
||||
return (
|
||||
<DashboardStoryCanvas>
|
||||
<TabsBar>
|
||||
{state.map((tab, index) => {
|
||||
return (
|
||||
<Tab
|
||||
key={index}
|
||||
label={tab.label}
|
||||
active={tab.active}
|
||||
disabled={tab.disabled}
|
||||
onChangeTab={() =>
|
||||
!tab.disabled && updateState(state.map((tab, idx) => ({ ...tab, active: idx === index })))
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</TabsBar>
|
||||
<TabContent>
|
||||
{state[0].active && <div>First tab content</div>}
|
||||
{state[1].active && <div>Second tab content (disabled)</div>}
|
||||
{state[2].active && <div>Third tab content</div>}
|
||||
</TabContent>
|
||||
</DashboardStoryCanvas>
|
||||
);
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
@@ -14,6 +14,9 @@ const setup = (jsx: JSX.Element) => {
|
||||
const onChangeTab = jest.fn();
|
||||
|
||||
describe('Tabs', () => {
|
||||
beforeEach(() => {
|
||||
onChangeTab.mockClear();
|
||||
});
|
||||
it('should call onChangeTab when clicking a tab', async () => {
|
||||
const { user } = setup(
|
||||
<TabsBar>
|
||||
@@ -96,4 +99,28 @@ describe('Tabs', () => {
|
||||
|
||||
expect(screen.getByTestId('tab-suffix')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render disabled tab correctly', () => {
|
||||
render(
|
||||
<TabsBar>
|
||||
<Tab label="Disabled Tab" active={false} onChangeTab={onChangeTab} disabled={true} />
|
||||
</TabsBar>
|
||||
);
|
||||
|
||||
const disabledTab = screen.getByRole('tab', { name: 'Disabled Tab' });
|
||||
expect(disabledTab).toHaveAttribute('aria-disabled', 'true');
|
||||
});
|
||||
|
||||
it('should not call onChangeTab when disabled tab is clicked', async () => {
|
||||
const { user } = setup(
|
||||
<TabsBar>
|
||||
<Tab label="Disabled Tab" active={false} onChangeTab={onChangeTab} disabled={true} />
|
||||
</TabsBar>
|
||||
);
|
||||
|
||||
const disabledTab = screen.getByRole('tab', { name: 'Disabled Tab' });
|
||||
await user.click(disabledTab);
|
||||
|
||||
expect(onChangeTab).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -57,8 +57,8 @@ func newLegacyAccessClient(ac accesscontrol.AccessControl, store legacy.LegacyId
|
||||
Resource: legacyiamv0.UserResourceInfo.GetName(),
|
||||
Attr: "id",
|
||||
Mapping: map[string]string{
|
||||
utils.VerbCreate: accesscontrol.ActionOrgUsersWrite,
|
||||
utils.VerbDelete: accesscontrol.ActionOrgUsersWrite,
|
||||
utils.VerbCreate: accesscontrol.ActionUsersCreate,
|
||||
utils.VerbDelete: accesscontrol.ActionUsersDelete,
|
||||
utils.VerbGet: accesscontrol.ActionOrgUsersRead,
|
||||
utils.VerbList: accesscontrol.ActionOrgUsersRead,
|
||||
},
|
||||
|
||||
@@ -2,6 +2,7 @@ package iam
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"maps"
|
||||
"strings"
|
||||
|
||||
@@ -264,12 +265,24 @@ func (b *IdentityAccessManagementAPIBuilder) Validate(ctx context.Context, a adm
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *IdentityAccessManagementAPIBuilder) validateCreateUser(_ context.Context, a admission.Attributes, o admission.ObjectInterfaces) error {
|
||||
func (b *IdentityAccessManagementAPIBuilder) validateCreateUser(ctx context.Context, a admission.Attributes, o admission.ObjectInterfaces) error {
|
||||
userObj, ok := a.GetObject().(*iamv0.User)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
requester, err := identity.GetRequester(ctx)
|
||||
if err != nil {
|
||||
return apierrors.NewBadRequest("no identity found")
|
||||
}
|
||||
|
||||
// Temporary validation that the user is not trying to create a Grafana Admin without being a Grafana Admin.
|
||||
if userObj.Spec.GrafanaAdmin && !requester.GetIsGrafanaAdmin() {
|
||||
return apierrors.NewForbidden(legacyiamv0.UserResourceInfo.GroupResource(),
|
||||
userObj.Name,
|
||||
fmt.Errorf("only grafana admins can create grafana admins"))
|
||||
}
|
||||
|
||||
if userObj.Spec.Login == "" && userObj.Spec.Email == "" {
|
||||
return apierrors.NewBadRequest("user must have either login or email")
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"k8s.io/apimachinery/pkg/api/errors"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||
|
||||
@@ -211,12 +212,13 @@ func TestIntegrationUsers(t *testing.T) {
|
||||
}
|
||||
|
||||
func doUserCRUDTestsUsingTheNewAPIs(t *testing.T, helper *apis.K8sTestHelper) {
|
||||
t.Run("should create user and delete it using the new APIs", func(t *testing.T) {
|
||||
t.Run("should create user and delete it using the new APIs as a GrafanaAdmin", func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
userClient := helper.GetResourceClient(apis.ResourceClientArgs{
|
||||
User: helper.Org1.Admin,
|
||||
GVR: gvrUsers,
|
||||
User: helper.Org1.Admin,
|
||||
Namespace: helper.Namespacer(helper.Org1.Admin.Identity.GetOrgID()),
|
||||
GVR: gvrUsers,
|
||||
})
|
||||
|
||||
// Create the user
|
||||
@@ -253,31 +255,36 @@ func doUserCRUDTestsUsingTheNewAPIs(t *testing.T, helper *apis.K8sTestHelper) {
|
||||
require.Equal(t, createdUID, fetched.GetName())
|
||||
require.Equal(t, "default", fetched.GetNamespace())
|
||||
|
||||
err = userClient.Resource.Delete(ctx, createdUID, metav1.DeleteOptions{})
|
||||
require.NoError(t, err)
|
||||
// TODO: Uncomment when we know how to handle global scope (global.users:)
|
||||
// err = userClient.Resource.Delete(ctx, createdUID, metav1.DeleteOptions{})
|
||||
// require.NoError(t, err)
|
||||
|
||||
// Verify deletion
|
||||
_, err = userClient.Resource.Get(ctx, createdUID, metav1.GetOptions{})
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "not found")
|
||||
// _, err = userClient.Resource.Get(ctx, createdUID, metav1.GetOptions{})
|
||||
// require.Error(t, err)
|
||||
// require.Contains(t, err.Error(), "not found")
|
||||
})
|
||||
|
||||
t.Run("should not be able to create user when using a user with insufficient permissions", func(t *testing.T) {
|
||||
for _, user := range []apis.User{
|
||||
helper.OrgB.Admin, // Not a Grafana Admin
|
||||
helper.Org1.Editor,
|
||||
helper.Org1.Viewer,
|
||||
} {
|
||||
t.Run(fmt.Sprintf("with basic role: %s", user.Identity.GetOrgRole()), func(t *testing.T) {
|
||||
t.Run(fmt.Sprintf("with basic role_%s", user.Identity.GetOrgRole()), func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
userClient := helper.GetResourceClient(apis.ResourceClientArgs{
|
||||
User: user,
|
||||
GVR: gvrUsers,
|
||||
User: user,
|
||||
Namespace: helper.Namespacer(helper.Org1.Admin.Identity.GetOrgID()),
|
||||
GVR: gvrUsers,
|
||||
})
|
||||
|
||||
// Create the user
|
||||
_, err := userClient.Resource.Create(ctx, helper.LoadYAMLOrJSONFile("testdata/user-test-create-v0.yaml"), metav1.CreateOptions{})
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "unauthorized request")
|
||||
var statusErr *errors.StatusError
|
||||
require.ErrorAs(t, err, &statusErr)
|
||||
require.Equal(t, int32(403), statusErr.ErrStatus.Code)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user