You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by en...@apache.org on 2025/05/29 20:15:37 UTC
(superset) branch enxdev/feat/fab-user-info-migration created (now d01d82c904)
This is an automated email from the ASF dual-hosted git repository.
enzomartellucci pushed a change to branch enxdev/feat/fab-user-info-migration
in repository https://gitbox.apache.org/repos/asf/superset.git
at d01d82c904 feat(UserInfo): migrate UserInfo to client-side rendering
This branch includes the following new commits:
new d01d82c904 feat(UserInfo): migrate UserInfo to client-side rendering
The 1 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails. The revisions
listed as "add" were already present in the repository and have only
been added to this reference.
(superset) 01/01: feat(UserInfo): migrate UserInfo to client-side rendering
Posted by en...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
enzomartellucci pushed a commit to branch enxdev/feat/fab-user-info-migration
in repository https://gitbox.apache.org/repos/asf/superset.git
commit d01d82c90465a9169a8a864d70eb5e1cf521d1d3
Author: Enzo Martellucci <en...@gmail.com>
AuthorDate: Thu May 29 22:15:12 2025 +0200
feat(UserInfo): migrate UserInfo to client-side rendering
---
.../src/components/Descriptions/index.tsx | 21 ++
.../src/components/Icons/AntdEnhanced.tsx | 2 +
.../src/components/Modal/FormModal.tsx | 2 +-
.../src/features/userInfo/UserInfoModal.tsx | 150 ++++++++++++++
.../src/pages/UserInfo/UserInfo.test.tsx | 126 ++++++++++++
superset-frontend/src/pages/UserInfo/index.tsx | 226 +++++++++++++++++++++
superset-frontend/src/types/bootstrapTypes.ts | 1 +
superset-frontend/src/views/routes.tsx | 5 +
superset/initialization/__init__.py | 2 +
superset/views/base.py | 4 +-
superset/views/{users/schemas.py => user_info.py} | 26 ++-
superset/views/users/api.py | 83 +++++++-
superset/views/users/schemas.py | 32 ++-
superset/views/utils.py | 1 +
tests/integration_tests/users/api_tests.py | 26 +++
15 files changed, 688 insertions(+), 19 deletions(-)
diff --git a/superset-frontend/src/components/Descriptions/index.tsx b/superset-frontend/src/components/Descriptions/index.tsx
new file mode 100644
index 0000000000..1d1de86d87
--- /dev/null
+++ b/superset-frontend/src/components/Descriptions/index.tsx
@@ -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';
diff --git a/superset-frontend/src/components/Icons/AntdEnhanced.tsx b/superset-frontend/src/components/Icons/AntdEnhanced.tsx
index 5221ca5826..65f2b4bd3d 100644
--- a/superset-frontend/src/components/Icons/AntdEnhanced.tsx
+++ b/superset-frontend/src/components/Icons/AntdEnhanced.tsx
@@ -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,
diff --git a/superset-frontend/src/components/Modal/FormModal.tsx b/superset-frontend/src/components/Modal/FormModal.tsx
index 6141f7806e..69956b15fb 100644
--- a/superset-frontend/src/components/Modal/FormModal.tsx
+++ b/superset-frontend/src/components/Modal/FormModal.tsx
@@ -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[];
diff --git a/superset-frontend/src/features/userInfo/UserInfoModal.tsx b/superset-frontend/src/features/userInfo/UserInfoModal.tsx
new file mode 100644
index 0000000000..42426946ad
--- /dev/null
+++ b/superset-frontend/src/features/userInfo/UserInfoModal.tsx
@@ -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 />;
diff --git a/superset-frontend/src/pages/UserInfo/UserInfo.test.tsx b/superset-frontend/src/pages/UserInfo/UserInfo.test.tsx
new file mode 100644
index 0000000000..479d9c3ee4
--- /dev/null
+++ b/superset-frontend/src/pages/UserInfo/UserInfo.test.tsx
@@ -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);
+ });
+});
diff --git a/superset-frontend/src/pages/UserInfo/index.tsx b/superset-frontend/src/pages/UserInfo/index.tsx
new file mode 100644
index 0000000000..f03d53faa2
--- /dev/null
+++ b/superset-frontend/src/pages/UserInfo/index.tsx
@@ -0,0 +1,226 @@
+/**
+ * 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 { Divider } from 'src/components/Divider';
+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 StyledLayout = styled.div`
+ background: ${({ theme }) => theme.colors.grayscale.light5};
+
+ .nav-right {
+ left: 0 !important;
+ margin-left: ${({ theme }) => theme.gridUnit * 7}px;
+ padding: 0 !important;
+ }
+`;
+
+const StyledHeader = styled.div`
+ ${({ theme }) => css`
+ font-weight: ${theme.typography.weights.bold};
+ text-align: left;
+ font-size: 18px;
+ padding: ${theme.gridUnit * 3}px;
+ display: inline-block;
+ line-height: ${theme.gridUnit * 9}px;
+ margin-left: ${theme.gridUnit * 4}px;
+ `}
+`;
+
+const DescriptionsContainer = styled.div`
+ margin-left: ${({ theme }) => theme.gridUnit * 3}px;
+`;
+
+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(() => {
+ console.log('Fetching user info');
+ 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.light5}
+ iconSize="m"
+ css={css`
+ margin: auto ${theme.gridUnit * 2}px auto 0;
+ vertical-align: text-top;
+ `}
+ />
+ {t('Reset my password')}
+ </>
+ ),
+ buttonStyle: 'primary',
+ 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>
+ <Divider />
+ <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>
+ <Divider />
+ {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;
diff --git a/superset-frontend/src/types/bootstrapTypes.ts b/superset-frontend/src/types/bootstrapTypes.ts
index ff46f6ab8c..5b87d12b3e 100644
--- a/superset-frontend/src/types/bootstrapTypes.ts
+++ b/superset-frontend/src/types/bootstrapTypes.ts
@@ -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][]>;
diff --git a/superset-frontend/src/views/routes.tsx b/superset-frontend/src/views/routes.tsx
index 8380ac58f0..06f6a34912 100644
--- a/superset-frontend/src/views/routes.tsx
+++ b/superset-frontend/src/views/routes.tsx
@@ -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'),
);
@@ -243,6 +247,7 @@ export const routes: Routes = [
path: '/sqllab/',
Component: SqlLab,
},
+ { path: '/user_info/', Component: UserInfo },
{
path: '/actionlog/list',
Component: ActionLogList,
diff --git a/superset/initialization/__init__.py b/superset/initialization/__init__.py
index 427fd69925..16cc18b644 100644
--- a/superset/initialization/__init__.py
+++ b/superset/initialization/__init__.py
@@ -184,6 +184,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
@@ -339,6 +340,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
diff --git a/superset/views/base.py b/superset/views/base.py
index 43b4f77671..67f2ec55f7 100644
--- a/superset/views/base.py
+++ b/superset/views/base.py
@@ -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,
diff --git a/superset/views/users/schemas.py b/superset/views/user_info.py
similarity index 62%
copy from superset/views/users/schemas.py
copy to superset/views/user_info.py
index 021209edaf..24449709e8 100644
--- a/superset/views/users/schemas.py
+++ b/superset/views/user_info.py
@@ -14,15 +14,21 @@
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
-from marshmallow import Schema
-from marshmallow.fields import Boolean, Integer, String
+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
-class UserResponseSchema(Schema):
- id = Integer()
- username = String()
- email = String()
- first_name = String()
- last_name = String()
- is_active = Boolean()
- is_anonymous = Boolean()
+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()
diff --git a/superset/views/users/api.py b/superset/views/users/api.py
index 82089fe84f..9f7ca6fc68 100644
--- a/superset/views/users/api.py
+++ b/superset/views/users/api.py
@@ -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,58 @@ 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)
+ 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"""
diff --git a/superset/views/users/schemas.py b/superset/views/users/schemas.py
index 021209edaf..f74d1cbca5 100644
--- a/superset/views/users/schemas.py
+++ b/superset/views/users/schemas.py
@@ -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},
+ )
diff --git a/superset/views/utils.py b/superset/views/utils.py
index 1f6a4348fd..819641fbe3 100644
--- a/superset/views/utils.py
+++ b/superset/views/utils.py
@@ -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:
diff --git a/tests/integration_tests/users/api_tests.py b/tests/integration_tests/users/api_tests.py
index 9f7423bb13..cd5e4c616b 100644
--- a/tests/integration_tests/users/api_tests.py
+++ b/tests/integration_tests/users/api_tests.py
@@ -67,6 +67,32 @@ 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"]
+
class TestUserApi(SupersetTestCase):
def test_avatar_with_invalid_user(self):