mirror of
https://github.com/apache/superset.git
synced 2026-04-19 08:04:53 +00:00
feat(UserInfo): Migrate User Info FAB to React (#33620)
This commit is contained in:
21
superset-frontend/src/components/Descriptions/index.tsx
Normal file
21
superset-frontend/src/components/Descriptions/index.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
export { Descriptions } from 'antd-v5';
|
||||
export type { DescriptionsProps } from 'antd-v5/es/descriptions';
|
||||
@@ -68,6 +68,7 @@ import {
|
||||
FileOutlined,
|
||||
FileTextOutlined,
|
||||
FireOutlined,
|
||||
FormOutlined,
|
||||
FullscreenExitOutlined,
|
||||
FullscreenOutlined,
|
||||
FundProjectionScreenOutlined,
|
||||
@@ -172,6 +173,7 @@ const AntdIcons = {
|
||||
FileOutlined,
|
||||
FileTextOutlined,
|
||||
FireOutlined,
|
||||
FormOutlined,
|
||||
FullscreenExitOutlined,
|
||||
FullscreenOutlined,
|
||||
FundProjectionScreenOutlined,
|
||||
|
||||
@@ -23,7 +23,7 @@ import { useState, useCallback } from 'react';
|
||||
import { t } from '@superset-ui/core';
|
||||
|
||||
export interface FormModalProps extends ModalProps {
|
||||
initialValues: Object;
|
||||
initialValues?: Object;
|
||||
formSubmitHandler: (values: Object) => Promise<void>;
|
||||
onSave: () => void;
|
||||
requiredFields: string[];
|
||||
|
||||
150
superset-frontend/src/features/userInfo/UserInfoModal.tsx
Normal file
150
superset-frontend/src/features/userInfo/UserInfoModal.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { SupersetClient, t } from '@superset-ui/core';
|
||||
import { useToasts } from 'src/components/MessageToasts/withToasts';
|
||||
import FormModal from 'src/components/Modal/FormModal';
|
||||
import { FormItem } from 'src/components/Form';
|
||||
import { Input } from 'src/components/Input';
|
||||
import { User } from 'src/types/bootstrapTypes';
|
||||
import { BaseUserListModalProps, FormValues } from '../users/types';
|
||||
|
||||
export interface UserInfoModalProps extends BaseUserListModalProps {
|
||||
isEditMode?: boolean;
|
||||
user?: User;
|
||||
}
|
||||
|
||||
function UserInfoModal({
|
||||
show,
|
||||
onHide,
|
||||
onSave,
|
||||
isEditMode,
|
||||
user,
|
||||
}: UserInfoModalProps) {
|
||||
const { addDangerToast, addSuccessToast } = useToasts();
|
||||
|
||||
const requiredFields = isEditMode
|
||||
? ['first_name', 'last_name']
|
||||
: ['password', 'confirm_password'];
|
||||
const initialValues = isEditMode
|
||||
? {
|
||||
first_name: user?.firstName,
|
||||
last_name: user?.lastName,
|
||||
}
|
||||
: {};
|
||||
const handleFormSubmit = async (values: FormValues) => {
|
||||
try {
|
||||
const { confirm_password, ...payload } = values;
|
||||
await SupersetClient.put({
|
||||
endpoint: `/api/v1/me/`,
|
||||
jsonPayload: { ...payload },
|
||||
});
|
||||
addSuccessToast(
|
||||
isEditMode
|
||||
? t('The user was updated successfully')
|
||||
: t('The password reset was successfull'),
|
||||
);
|
||||
onSave();
|
||||
} catch (error) {
|
||||
addDangerToast(t('Something went wrong while saving the user info'));
|
||||
}
|
||||
};
|
||||
|
||||
const EditModeFields = () => (
|
||||
<>
|
||||
<FormItem
|
||||
name="first_name"
|
||||
label={t('First name')}
|
||||
rules={[{ required: true, message: t('First name is required') }]}
|
||||
>
|
||||
<Input
|
||||
name="first_name"
|
||||
placeholder={t("Enter the user's first name")}
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem
|
||||
name="last_name"
|
||||
label={t('Last name')}
|
||||
rules={[{ required: true, message: t('Last name is required') }]}
|
||||
>
|
||||
<Input name="last_name" placeholder={t("Enter the user's last name")} />
|
||||
</FormItem>
|
||||
</>
|
||||
);
|
||||
|
||||
const ResetPasswordFields = () => (
|
||||
<>
|
||||
<FormItem
|
||||
name="password"
|
||||
label={t('Password')}
|
||||
rules={[{ required: true, message: t('Password is required') }]}
|
||||
>
|
||||
<Input.Password
|
||||
name="password"
|
||||
placeholder="Enter the user's password"
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem
|
||||
name="confirm_password"
|
||||
label={t('Confirm Password')}
|
||||
dependencies={['password']}
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: t('Please confirm your password'),
|
||||
},
|
||||
({ getFieldValue }) => ({
|
||||
validator(_, value) {
|
||||
if (!value || getFieldValue('password') === value) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
return Promise.reject(new Error(t('Passwords do not match!')));
|
||||
},
|
||||
}),
|
||||
]}
|
||||
>
|
||||
<Input.Password
|
||||
name="confirm_password"
|
||||
placeholder={t("Confirm the user's password")}
|
||||
/>
|
||||
</FormItem>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<FormModal
|
||||
show={show}
|
||||
onHide={onHide}
|
||||
title={isEditMode ? t('Edit user') : t('Reset password')}
|
||||
onSave={onSave}
|
||||
formSubmitHandler={handleFormSubmit}
|
||||
requiredFields={requiredFields}
|
||||
initialValues={initialValues}
|
||||
>
|
||||
{isEditMode ? <EditModeFields /> : <ResetPasswordFields />}
|
||||
</FormModal>
|
||||
);
|
||||
}
|
||||
|
||||
export const UserInfoResetPasswordModal = (
|
||||
props: Omit<UserInfoModalProps, 'isEditMode' | 'user'>,
|
||||
) => <UserInfoModal {...props} isEditMode={false} />;
|
||||
|
||||
export const UserInfoEditModal = (
|
||||
props: Omit<UserInfoModalProps, 'isEditMode'> & { user: User },
|
||||
) => <UserInfoModal {...props} isEditMode />;
|
||||
126
superset-frontend/src/pages/UserInfo/UserInfo.test.tsx
Normal file
126
superset-frontend/src/pages/UserInfo/UserInfo.test.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import fetchMock from 'fetch-mock';
|
||||
import {
|
||||
render,
|
||||
screen,
|
||||
fireEvent,
|
||||
waitFor,
|
||||
act,
|
||||
} from 'spec/helpers/testing-library';
|
||||
import configureStore from 'redux-mock-store';
|
||||
import thunk from 'redux-thunk';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { QueryParamProvider } from 'use-query-params';
|
||||
import UserInfo from 'src/pages/UserInfo';
|
||||
import { UserWithPermissionsAndRoles } from 'src/types/bootstrapTypes';
|
||||
|
||||
const mockStore = configureStore([thunk]);
|
||||
const store = mockStore({});
|
||||
|
||||
const meEndpoint = 'glob:*/api/v1/me/';
|
||||
|
||||
const mockUser: UserWithPermissionsAndRoles = {
|
||||
userId: 1,
|
||||
firstName: 'John',
|
||||
lastName: 'Doe',
|
||||
username: 'johndoe',
|
||||
email: 'john@example.com',
|
||||
isActive: true,
|
||||
loginCount: 12,
|
||||
roles: {
|
||||
Admin: [
|
||||
['can_read', 'Dashboard'],
|
||||
['can_write', 'Chart'],
|
||||
],
|
||||
},
|
||||
createdOn: new Date().toISOString(),
|
||||
isAnonymous: false,
|
||||
permissions: {
|
||||
database_access: ['examples', 'birth_names'],
|
||||
datasource_access: ['examples.babynames', 'examples.world_health'],
|
||||
},
|
||||
};
|
||||
|
||||
describe('UserInfo', () => {
|
||||
const renderPage = async () => {
|
||||
await act(async () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<QueryParamProvider>
|
||||
<UserInfo user={mockUser} />
|
||||
</QueryParamProvider>
|
||||
</MemoryRouter>,
|
||||
{ useRedux: true, store },
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock.restore();
|
||||
fetchMock.get(meEndpoint, {
|
||||
result: {
|
||||
...mockUser,
|
||||
first_name: 'John',
|
||||
last_name: 'Doe',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
fetchMock.restore();
|
||||
});
|
||||
|
||||
it('renders the user info page', async () => {
|
||||
await renderPage();
|
||||
|
||||
expect(
|
||||
await screen.findByText('Your user information'),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText('johndoe')).toBeInTheDocument();
|
||||
expect(screen.getByText('Yes')).toBeInTheDocument();
|
||||
expect(screen.getByText('Admin')).toBeInTheDocument();
|
||||
expect(screen.getByText('12')).toBeInTheDocument();
|
||||
expect(await screen.findByText('John')).toBeInTheDocument();
|
||||
expect(screen.getByText('Doe')).toBeInTheDocument();
|
||||
expect(screen.getByText('john@example.com')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls the /me endpoint on mount', async () => {
|
||||
await renderPage();
|
||||
await waitFor(() => {
|
||||
expect(fetchMock.called(meEndpoint)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('opens the reset password modal on button click', async () => {
|
||||
await renderPage();
|
||||
const button = await screen.findByTestId('reset-password-button');
|
||||
fireEvent.click(button);
|
||||
expect(await screen.findByText(/Reset password/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens the edit user modal on button click', async () => {
|
||||
await renderPage();
|
||||
const button = await screen.findByTestId('edit-user-button');
|
||||
fireEvent.click(button);
|
||||
expect(await screen.getAllByText(/Edit user/i).length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
238
superset-frontend/src/pages/UserInfo/index.tsx
Normal file
238
superset-frontend/src/pages/UserInfo/index.tsx
Normal file
@@ -0,0 +1,238 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { css, t, SupersetClient, useTheme, styled } from '@superset-ui/core';
|
||||
import SubMenu, { SubMenuProps } from 'src/features/home/SubMenu';
|
||||
import { Icons } from 'src/components/Icons';
|
||||
import { useToasts } from 'src/components/MessageToasts/withToasts';
|
||||
import { Descriptions } from 'src/components/Descriptions';
|
||||
import { UserWithPermissionsAndRoles } from 'src/types/bootstrapTypes';
|
||||
import {
|
||||
UserInfoEditModal,
|
||||
UserInfoResetPasswordModal,
|
||||
} from 'src/features/userInfo/UserInfoModal';
|
||||
import Collapse from 'src/components/Collapse';
|
||||
|
||||
interface UserInfoProps {
|
||||
user: UserWithPermissionsAndRoles;
|
||||
}
|
||||
|
||||
const StyledHeader = styled.div`
|
||||
${({ theme }) => css`
|
||||
font-weight: ${theme.typography.weights.bold};
|
||||
text-align: left;
|
||||
font-size: 18px;
|
||||
padding: ${theme.gridUnit * 3}px;
|
||||
padding-left: ${theme.gridUnit * 7}px;
|
||||
display: inline-block;
|
||||
line-height: ${theme.gridUnit * 9}px;
|
||||
width: 100%;
|
||||
background-color: ${theme.colors.grayscale.light5};
|
||||
margin-bottom: ${theme.gridUnit * 6}px;
|
||||
`}
|
||||
`;
|
||||
|
||||
const DescriptionsContainer = styled.div`
|
||||
${({ theme }) => css`
|
||||
margin: 0px ${theme.gridUnit * 3}px ${theme.gridUnit * 6}px
|
||||
${theme.gridUnit * 3}px;
|
||||
background-color: ${theme.colors.grayscale.light5};
|
||||
`}
|
||||
`;
|
||||
|
||||
const StyledLayout = styled.div`
|
||||
${({ theme }) => css`
|
||||
.antd5-row {
|
||||
margin: 0px ${theme.gridUnit * 3}px ${theme.gridUnit * 6}px
|
||||
${theme.gridUnit * 3}px;
|
||||
}
|
||||
&& .menu > .antd5-menu {
|
||||
padding: 0px;
|
||||
}
|
||||
&& .nav-right {
|
||||
left: 0;
|
||||
padding-left: ${theme.gridUnit * 4}px;
|
||||
position: relative;
|
||||
height: ${theme.gridUnit * 15}px;
|
||||
background-color: ${theme.colors.grayscale.light5};
|
||||
}
|
||||
`}
|
||||
`;
|
||||
|
||||
const DescriptionTitle = styled.span`
|
||||
font-weight: ${({ theme }) => theme.typography.weights.bold};
|
||||
`;
|
||||
|
||||
enum ModalType {
|
||||
ResetPassword = 'resetPassword',
|
||||
Edit = 'edit',
|
||||
}
|
||||
|
||||
export function UserInfo({ user }: UserInfoProps) {
|
||||
const theme = useTheme();
|
||||
const [modalState, setModalState] = useState({
|
||||
resetPassword: false,
|
||||
edit: false,
|
||||
});
|
||||
const openModal = (type: ModalType) =>
|
||||
setModalState(prev => ({ ...prev, [type]: true }));
|
||||
const closeModal = (type: ModalType) =>
|
||||
setModalState(prev => ({ ...prev, [type]: false }));
|
||||
const { addDangerToast } = useToasts();
|
||||
const [userDetails, setUserDetails] = useState(user);
|
||||
|
||||
useEffect(() => {
|
||||
getUserDetails();
|
||||
}, []);
|
||||
|
||||
const getUserDetails = useCallback(() => {
|
||||
SupersetClient.get({ endpoint: '/api/v1/me/' })
|
||||
.then(({ json }) => {
|
||||
const transformedUser = {
|
||||
...json.result,
|
||||
firstName: json.result.first_name,
|
||||
lastName: json.result.last_name,
|
||||
};
|
||||
setUserDetails(transformedUser);
|
||||
})
|
||||
.catch(error => {
|
||||
addDangerToast('Failed to fetch user info:', error);
|
||||
});
|
||||
}, [userDetails]);
|
||||
|
||||
const SubMenuButtons: SubMenuProps['buttons'] = [
|
||||
{
|
||||
name: (
|
||||
<>
|
||||
<Icons.LockOutlined
|
||||
iconColor={theme.colors.primary.base}
|
||||
iconSize="m"
|
||||
css={css`
|
||||
margin: auto ${theme.gridUnit * 2}px auto 0;
|
||||
vertical-align: text-top;
|
||||
`}
|
||||
/>
|
||||
{t('Reset my password')}
|
||||
</>
|
||||
),
|
||||
buttonStyle: 'secondary',
|
||||
onClick: () => {
|
||||
openModal(ModalType.ResetPassword);
|
||||
},
|
||||
'data-test': 'reset-password-button',
|
||||
},
|
||||
{
|
||||
name: (
|
||||
<>
|
||||
<Icons.FormOutlined
|
||||
iconColor={theme.colors.primary.light5}
|
||||
iconSize="m"
|
||||
css={css`
|
||||
margin: auto ${theme.gridUnit * 2}px auto 0;
|
||||
vertical-align: text-top;
|
||||
`}
|
||||
/>
|
||||
{t('Edit user')}
|
||||
</>
|
||||
),
|
||||
buttonStyle: 'primary',
|
||||
onClick: () => {
|
||||
openModal(ModalType.Edit);
|
||||
},
|
||||
'data-test': 'edit-user-button',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<StyledLayout>
|
||||
<StyledHeader>Your user information</StyledHeader>
|
||||
<DescriptionsContainer>
|
||||
<Collapse defaultActiveKey={['userInfo', 'personalInfo']} ghost>
|
||||
<Collapse.Panel
|
||||
header={<DescriptionTitle>User info</DescriptionTitle>}
|
||||
key="userInfo"
|
||||
>
|
||||
<Descriptions
|
||||
bordered
|
||||
size="small"
|
||||
column={1}
|
||||
labelStyle={{ width: '120px' }}
|
||||
>
|
||||
<Descriptions.Item label="User Name">
|
||||
{user.username}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="Is Active?">
|
||||
{user.isActive ? 'Yes' : 'No'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="Role">
|
||||
{user.roles ? Object.keys(user.roles).join(', ') : 'None'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="Login count">
|
||||
{user.loginCount}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Collapse.Panel>
|
||||
<Collapse.Panel
|
||||
header={<DescriptionTitle>Personal info</DescriptionTitle>}
|
||||
key="personalInfo"
|
||||
>
|
||||
<Descriptions
|
||||
bordered
|
||||
size="small"
|
||||
column={1}
|
||||
labelStyle={{ width: '120px' }}
|
||||
>
|
||||
<Descriptions.Item label="First Name">
|
||||
{userDetails.firstName}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="Last Name">
|
||||
{userDetails.lastName}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="Email">{user.email}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Collapse.Panel>
|
||||
</Collapse>
|
||||
</DescriptionsContainer>
|
||||
{modalState.resetPassword && (
|
||||
<UserInfoResetPasswordModal
|
||||
onHide={() => closeModal(ModalType.ResetPassword)}
|
||||
show={modalState.resetPassword}
|
||||
onSave={() => {
|
||||
closeModal(ModalType.ResetPassword);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{modalState.edit && (
|
||||
<UserInfoEditModal
|
||||
onHide={() => closeModal(ModalType.Edit)}
|
||||
show={modalState.edit}
|
||||
onSave={() => {
|
||||
closeModal(ModalType.Edit);
|
||||
getUserDetails();
|
||||
}}
|
||||
user={userDetails}
|
||||
/>
|
||||
)}
|
||||
<SubMenu buttons={SubMenuButtons} />
|
||||
</StyledLayout>
|
||||
);
|
||||
}
|
||||
|
||||
export default UserInfo;
|
||||
@@ -39,6 +39,7 @@ export type User = {
|
||||
lastName: string;
|
||||
userId?: number; // optional because guest user doesn't have a user id
|
||||
username: string;
|
||||
loginCount?: number;
|
||||
};
|
||||
|
||||
export type UserRoles = Record<string, [string, string][]>;
|
||||
|
||||
@@ -137,6 +137,10 @@ const RolesList = lazy(
|
||||
const UsersList: LazyExoticComponent<any> = lazy(
|
||||
() => import(/* webpackChunkName: "UsersList" */ 'src/pages/UsersList'),
|
||||
);
|
||||
|
||||
const UserInfo = lazy(
|
||||
() => import(/* webpackChunkName: "UserInfo" */ 'src/pages/UserInfo'),
|
||||
);
|
||||
const ActionLogList: LazyExoticComponent<any> = lazy(
|
||||
() => import(/* webpackChunkName: "ActionLogList" */ 'src/pages/ActionLog'),
|
||||
);
|
||||
@@ -246,6 +250,7 @@ export const routes: Routes = [
|
||||
path: '/sqllab/',
|
||||
Component: SqlLab,
|
||||
},
|
||||
{ path: '/user_info/', Component: UserInfo },
|
||||
{
|
||||
path: '/actionlog/list',
|
||||
Component: ActionLogList,
|
||||
|
||||
@@ -185,6 +185,7 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods
|
||||
)
|
||||
from superset.views.sqllab import SqllabView
|
||||
from superset.views.tags import TagModelView, TagView
|
||||
from superset.views.user_info import UserInfoView
|
||||
from superset.views.users.api import CurrentUserRestApi, UserRestApi
|
||||
from superset.views.users_list import UsersListView
|
||||
|
||||
@@ -348,6 +349,7 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods
|
||||
appbuilder.add_view_no_menu(TagView)
|
||||
appbuilder.add_view_no_menu(ReportView)
|
||||
appbuilder.add_view_no_menu(RoleRestAPI)
|
||||
appbuilder.add_view_no_menu(UserInfoView)
|
||||
|
||||
#
|
||||
# Add links
|
||||
|
||||
@@ -282,9 +282,7 @@ def menu_data(user: User) -> dict[str, Any]:
|
||||
"show_language_picker": len(languages) > 1,
|
||||
"user_is_anonymous": user.is_anonymous,
|
||||
"user_info_url": (
|
||||
None
|
||||
if is_feature_enabled("MENU_HIDE_USER_INFO")
|
||||
else appbuilder.get_url_for_userinfo
|
||||
None if is_feature_enabled("MENU_HIDE_USER_INFO") else "/user_info/"
|
||||
),
|
||||
"user_logout_url": appbuilder.get_url_for_logout,
|
||||
"user_login_url": appbuilder.get_url_for_login,
|
||||
|
||||
34
superset/views/user_info.py
Normal file
34
superset/views/user_info.py
Normal file
@@ -0,0 +1,34 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
from flask_appbuilder import permission_name
|
||||
from flask_appbuilder.api import expose
|
||||
from flask_appbuilder.security.decorators import has_access
|
||||
|
||||
from superset.superset_typing import FlaskResponse
|
||||
|
||||
from .base import BaseSupersetView
|
||||
|
||||
|
||||
class UserInfoView(BaseSupersetView):
|
||||
route_base = "/"
|
||||
class_permission_name = "user"
|
||||
|
||||
@expose("/user_info/")
|
||||
@has_access
|
||||
@permission_name("read")
|
||||
def list(self) -> FlaskResponse:
|
||||
return super().render_app_template()
|
||||
@@ -14,16 +14,23 @@
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
from flask import g, redirect, Response
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict
|
||||
|
||||
from flask import g, redirect, request, Response
|
||||
from flask_appbuilder.api import expose, safe
|
||||
from flask_appbuilder.security.sqla.models import User
|
||||
from flask_jwt_extended.exceptions import NoAuthorizationError
|
||||
from marshmallow import ValidationError
|
||||
from sqlalchemy.orm.exc import NoResultFound
|
||||
from werkzeug.security import generate_password_hash
|
||||
|
||||
from superset import app, is_feature_enabled
|
||||
from superset.daos.user import UserDAO
|
||||
from superset.extensions import db, event_logger
|
||||
from superset.utils.slack import get_user_avatar, SlackClientError
|
||||
from superset.views.base_api import BaseSupersetApi
|
||||
from superset.views.users.schemas import UserResponseSchema
|
||||
from superset.views.base_api import BaseSupersetApi, requires_json, statsd_metrics
|
||||
from superset.views.users.schemas import CurrentUserPutSchema, UserResponseSchema
|
||||
from superset.views.utils import bootstrap_user_data
|
||||
|
||||
user_response_schema = UserResponseSchema()
|
||||
@@ -34,7 +41,23 @@ class CurrentUserRestApi(BaseSupersetApi):
|
||||
|
||||
resource_name = "me"
|
||||
openapi_spec_tag = "Current User"
|
||||
openapi_spec_component_schemas = (UserResponseSchema,)
|
||||
openapi_spec_component_schemas = (UserResponseSchema, CurrentUserPutSchema)
|
||||
|
||||
current_user_put_schema = CurrentUserPutSchema()
|
||||
|
||||
def pre_update(self, item: User, data: Dict[str, Any]) -> None:
|
||||
item.changed_on = datetime.now()
|
||||
item.changed_by_fk = g.user.id
|
||||
if "password" in data and data["password"]:
|
||||
item.password = generate_password_hash(
|
||||
password=data["password"],
|
||||
method=self.appbuilder.get_app.config.get(
|
||||
"FAB_PASSWORD_HASH_METHOD", "scrypt"
|
||||
),
|
||||
salt_length=self.appbuilder.get_app.config.get(
|
||||
"FAB_PASSWORD_HASH_SALT_LENGTH", 16
|
||||
),
|
||||
)
|
||||
|
||||
@expose("/", methods=("GET",))
|
||||
@safe
|
||||
@@ -98,6 +121,61 @@ class CurrentUserRestApi(BaseSupersetApi):
|
||||
user = bootstrap_user_data(g.user, include_perms=True)
|
||||
return self.response(200, result=user)
|
||||
|
||||
@expose("/", methods=["PUT"])
|
||||
@safe
|
||||
@statsd_metrics
|
||||
@event_logger.log_this_with_context(
|
||||
action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.put",
|
||||
log_to_statsd=False,
|
||||
)
|
||||
@requires_json
|
||||
def update_me(self) -> Response:
|
||||
"""Update current user information
|
||||
---
|
||||
put:
|
||||
summary: Update the current user
|
||||
description: >-
|
||||
Updates the current user's first name, last name, or password.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/CurrentUserPutSchema'
|
||||
responses:
|
||||
200:
|
||||
description: User updated successfully
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
result:
|
||||
$ref: '#/components/schemas/UserResponseSchema'
|
||||
400:
|
||||
$ref: '#/components/responses/400'
|
||||
401:
|
||||
$ref: '#/components/responses/401'
|
||||
"""
|
||||
try:
|
||||
if g.user is None or g.user.is_anonymous:
|
||||
return self.response_401()
|
||||
except NoAuthorizationError:
|
||||
return self.response_401()
|
||||
try:
|
||||
item = self.current_user_put_schema.load(request.json)
|
||||
if not item:
|
||||
return self.response_400(message="At least one field must be provided.")
|
||||
|
||||
for key, value in item.items():
|
||||
setattr(g.user, key, value)
|
||||
|
||||
self.pre_update(g.user, item)
|
||||
db.session.commit()
|
||||
return self.response(200, result=user_response_schema.dump(g.user))
|
||||
except ValidationError as error:
|
||||
return self.response_400(message=error.messages)
|
||||
|
||||
|
||||
class UserRestApi(BaseSupersetApi):
|
||||
"""An API to get information about users"""
|
||||
|
||||
@@ -14,8 +14,17 @@
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
from marshmallow import Schema
|
||||
from flask_appbuilder.security.sqla.apis.user.schema import User
|
||||
from flask_appbuilder.security.sqla.apis.user.validator import (
|
||||
PasswordComplexityValidator,
|
||||
)
|
||||
from marshmallow import fields, Schema
|
||||
from marshmallow.fields import Boolean, Integer, String
|
||||
from marshmallow.validate import Length
|
||||
|
||||
first_name_description = "The current user's first name"
|
||||
last_name_description = "The current user's last name"
|
||||
password_description = "The current user's password for authentication" # noqa: S105
|
||||
|
||||
|
||||
class UserResponseSchema(Schema):
|
||||
@@ -26,3 +35,24 @@ class UserResponseSchema(Schema):
|
||||
last_name = String()
|
||||
is_active = Boolean()
|
||||
is_anonymous = Boolean()
|
||||
login_count = Integer()
|
||||
|
||||
|
||||
class CurrentUserPutSchema(Schema):
|
||||
model_cls = User
|
||||
|
||||
first_name = fields.String(
|
||||
required=False,
|
||||
metadata={"description": first_name_description},
|
||||
validate=[Length(1, 64)],
|
||||
)
|
||||
last_name = fields.String(
|
||||
required=False,
|
||||
metadata={"description": last_name_description},
|
||||
validate=[Length(1, 64)],
|
||||
)
|
||||
password = fields.String(
|
||||
required=False,
|
||||
validate=[PasswordComplexityValidator()],
|
||||
metadata={"description": password_description},
|
||||
)
|
||||
|
||||
@@ -90,6 +90,7 @@ def bootstrap_user_data(user: User, include_perms: bool = False) -> dict[str, An
|
||||
"isAnonymous": user.is_anonymous,
|
||||
"createdOn": user.created_on.isoformat(),
|
||||
"email": user.email,
|
||||
"loginCount": user.login_count,
|
||||
}
|
||||
|
||||
if include_perms:
|
||||
|
||||
@@ -1538,6 +1538,7 @@ class TestRolePermission(SupersetTestCase):
|
||||
["AuthDBView", "login"],
|
||||
["AuthDBView", "logout"],
|
||||
["CurrentUserRestApi", "get_me"],
|
||||
["CurrentUserRestApi", "update_me"],
|
||||
["CurrentUserRestApi", "get_my_roles"],
|
||||
["UserRestApi", "avatar"],
|
||||
# TODO (embedded) remove Dashboard:embedded after uuids have been shipped
|
||||
|
||||
@@ -67,6 +67,37 @@ class TestCurrentUserApi(SupersetTestCase):
|
||||
rv = self.client.get(meUri)
|
||||
assert 401 == rv.status_code
|
||||
|
||||
def test_update_me_success(self):
|
||||
self.login(ADMIN_USERNAME)
|
||||
|
||||
payload = {
|
||||
"first_name": "UpdatedFirst",
|
||||
"last_name": "UpdatedLast",
|
||||
}
|
||||
|
||||
rv = self.client.put("/api/v1/me/", json=payload)
|
||||
assert rv.status_code == 200
|
||||
|
||||
data = json.loads(rv.data.decode("utf-8"))
|
||||
assert data["result"]["first_name"] == "UpdatedFirst"
|
||||
assert data["result"]["last_name"] == "UpdatedLast"
|
||||
|
||||
def test_update_me_unauthenticated(self):
|
||||
rv = self.client.put("/api/v1/me/", json={"first_name": "Hacker"})
|
||||
assert rv.status_code == 401
|
||||
|
||||
def test_update_me_invalid_payload(self):
|
||||
self.login(ADMIN_USERNAME)
|
||||
rv = self.client.put("/api/v1/me/", json={"first_name": 123})
|
||||
assert rv.status_code == 400
|
||||
data = json.loads(rv.data.decode("utf-8"))
|
||||
assert "first_name" in data["message"]
|
||||
|
||||
def test_update_me_empty_payload(self):
|
||||
self.login(ADMIN_USERNAME)
|
||||
rv = self.client.put("/api/v1/me/", json={})
|
||||
assert rv.status_code == 400
|
||||
|
||||
|
||||
class TestUserApi(SupersetTestCase):
|
||||
def test_avatar_with_invalid_user(self):
|
||||
|
||||
Reference in New Issue
Block a user