Merge remote-tracking branch 'origin/main' into ds-apiserver-with-configs

This commit is contained in:
Ryan McKinley
2025-08-21 14:50:30 +03:00
6 changed files with 129 additions and 19 deletions
@@ -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();
});
});
+2 -2
View File
@@ -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,
},
+14 -1
View File
@@ -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")
}
+19 -12
View File
@@ -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)
})
}
})