mirror of
https://github.com/grafana/grafana.git
synced 2026-09-05 04:40:13 -05:00
Admin: Fixes infinite loading edit profile page (#34627)
* UserProfile: Fixes infinite loading spinner * Refactor: some clean up * Refactor: some more cleanup * Tests: Adds tests for UserProfileEditPage * Chore: updates after PR comments * Refactor: removes unnecessary unmount/mount
This commit is contained in:
@@ -1,9 +1,10 @@
|
||||
import React, { FC } from 'react';
|
||||
import { css } from '@emotion/css';
|
||||
import { Button, Field, Form, HorizontalGroup, Input, LinkButton } from '@grafana/ui';
|
||||
|
||||
import config from 'app/core/config';
|
||||
import { UserDTO } from 'app/types';
|
||||
import { Button, LinkButton, Form, Field, Input, HorizontalGroup } from '@grafana/ui';
|
||||
import { ChangePasswordFields } from 'app/core/utils/UserProvider';
|
||||
import { css } from '@emotion/css';
|
||||
import { ChangePasswordFields } from './types';
|
||||
|
||||
export interface Props {
|
||||
user: UserDTO;
|
||||
|
||||
@@ -1,51 +1,53 @@
|
||||
import React, { FC } from 'react';
|
||||
import React from 'react';
|
||||
import { useMount } from 'react-use';
|
||||
import { hot } from 'react-hot-loader';
|
||||
import { connect } from 'react-redux';
|
||||
import { config } from '@grafana/runtime';
|
||||
import { LoadingPlaceholder } from '@grafana/ui';
|
||||
import { UserDTO, Team, UserOrg, UserSession, StoreState } from 'app/types';
|
||||
import { connect, ConnectedProps } from 'react-redux';
|
||||
import { NavModel } from '@grafana/data';
|
||||
|
||||
import { StoreState } from 'app/types';
|
||||
import { getNavModel } from 'app/core/selectors/navModel';
|
||||
import { UserProvider, UserAPI, LoadingStates } from 'app/core/utils/UserProvider';
|
||||
import Page from 'app/core/components/Page/Page';
|
||||
import { ChangePasswordForm } from './ChangePasswordForm';
|
||||
import { changePassword, loadUser } from './state/actions';
|
||||
|
||||
export interface Props {
|
||||
export interface OwnProps {
|
||||
navModel: NavModel;
|
||||
}
|
||||
|
||||
export const ChangePasswordPage: FC<Props> = ({ navModel }) => (
|
||||
<Page navModel={navModel}>
|
||||
<UserProvider userId={config.bootData.user.id}>
|
||||
{(
|
||||
api: UserAPI,
|
||||
states: LoadingStates,
|
||||
teams: Team[],
|
||||
orgs: UserOrg[],
|
||||
sessions: UserSession[],
|
||||
user?: UserDTO
|
||||
) => {
|
||||
return (
|
||||
<Page.Contents>
|
||||
<h3 className="page-heading">Change Your Password</h3>
|
||||
{states.loadUser ? (
|
||||
<LoadingPlaceholder text="Loading user profile..." />
|
||||
) : (
|
||||
<ChangePasswordForm user={user!} onChangePassword={api.changePassword} isSaving={states.changePassword} />
|
||||
)}
|
||||
</Page.Contents>
|
||||
);
|
||||
}}
|
||||
</UserProvider>
|
||||
</Page>
|
||||
);
|
||||
|
||||
function mapStateToProps(state: StoreState) {
|
||||
const userState = state.user;
|
||||
const { isUpdating, user } = userState;
|
||||
return {
|
||||
navModel: getNavModel(state.navIndex, `change-password`),
|
||||
isUpdating,
|
||||
user,
|
||||
};
|
||||
}
|
||||
|
||||
const mapDispatchToProps = {};
|
||||
const mapDispatchToProps = {
|
||||
loadUser,
|
||||
changePassword,
|
||||
};
|
||||
|
||||
export default hot(module)(connect(mapStateToProps, mapDispatchToProps)(ChangePasswordPage));
|
||||
const connector = connect(mapStateToProps, mapDispatchToProps);
|
||||
|
||||
type Props = OwnProps & ConnectedProps<typeof connector>;
|
||||
|
||||
export function ChangePasswordPage({ navModel, loadUser, isUpdating, user, changePassword }: Props) {
|
||||
useMount(() => loadUser());
|
||||
|
||||
return (
|
||||
<Page navModel={navModel}>
|
||||
<Page.Contents isLoading={!Boolean(user)}>
|
||||
{user ? (
|
||||
<>
|
||||
<h3 className="page-heading">Change Your Password</h3>
|
||||
<ChangePasswordForm user={user} onChangePassword={changePassword} isSaving={isUpdating} />
|
||||
</>
|
||||
) : null}
|
||||
</Page.Contents>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
||||
export default hot(module)(connector(ChangePasswordPage));
|
||||
|
||||
@@ -1,20 +1,15 @@
|
||||
import React, { PureComponent } from 'react';
|
||||
import { UserDTO, UserOrg } from 'app/types';
|
||||
import { LoadingPlaceholder, Button } from '@grafana/ui';
|
||||
import { Button, LoadingPlaceholder } from '@grafana/ui';
|
||||
|
||||
export interface Props {
|
||||
user: UserDTO;
|
||||
user: UserDTO | null;
|
||||
orgs: UserOrg[];
|
||||
isLoading: boolean;
|
||||
loadOrgs: () => void;
|
||||
setUserOrg: (org: UserOrg) => void;
|
||||
}
|
||||
|
||||
export class UserOrganizations extends PureComponent<Props> {
|
||||
componentDidMount() {
|
||||
this.props.loadOrgs();
|
||||
}
|
||||
|
||||
render() {
|
||||
const { isLoading, orgs, user } = this.props;
|
||||
|
||||
@@ -30,7 +25,7 @@ export class UserOrganizations extends PureComponent<Props> {
|
||||
<div>
|
||||
<h3 className="page-sub-heading">Organizations</h3>
|
||||
<div className="gf-form-group">
|
||||
<table className="filter-table form-inline">
|
||||
<table className="filter-table form-inline" aria-label="User organizations table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
@@ -45,7 +40,7 @@ export class UserOrganizations extends PureComponent<Props> {
|
||||
<td>{org.name}</td>
|
||||
<td>{org.role}</td>
|
||||
<td className="text-right">
|
||||
{org.orgId === user.orgId ? (
|
||||
{org.orgId === user?.orgId ? (
|
||||
<Button variant="secondary" size="sm" disabled>
|
||||
Current
|
||||
</Button>
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
import React, { FC } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { hot } from 'react-hot-loader';
|
||||
import { LoadingPlaceholder, VerticalGroup } from '@grafana/ui';
|
||||
import { config } from '@grafana/runtime';
|
||||
import { NavModel } from '@grafana/data';
|
||||
import { UserProvider, UserAPI, LoadingStates } from 'app/core/utils/UserProvider';
|
||||
import { getNavModel } from 'app/core/selectors/navModel';
|
||||
import { UserDTO, Team, UserOrg, UserSession, StoreState } from 'app/types';
|
||||
import { SharedPreferences } from 'app/core/components/SharedPreferences/SharedPreferences';
|
||||
import Page from 'app/core/components/Page/Page';
|
||||
import { UserTeams } from './UserTeams';
|
||||
import { UserSessions } from './UserSessions';
|
||||
import { UserOrganizations } from './UserOrganizations';
|
||||
import { UserProfileEditForm } from './UserProfileEditForm';
|
||||
|
||||
export interface Props {
|
||||
navModel: NavModel;
|
||||
}
|
||||
|
||||
export const UserProfileEdit: FC<Props> = ({ navModel }) => (
|
||||
<Page navModel={navModel}>
|
||||
<UserProvider userId={config.bootData.user.id}>
|
||||
{(
|
||||
api: UserAPI,
|
||||
states: LoadingStates,
|
||||
teams: Team[],
|
||||
orgs: UserOrg[],
|
||||
sessions: UserSession[],
|
||||
user?: UserDTO
|
||||
) => {
|
||||
return (
|
||||
<Page.Contents>
|
||||
{states.loadUser ? (
|
||||
<LoadingPlaceholder text="Loading user profile..." />
|
||||
) : (
|
||||
<VerticalGroup spacing="md">
|
||||
<UserProfileEditForm
|
||||
updateProfile={api.updateUserProfile}
|
||||
isSavingUser={states.updateUserProfile}
|
||||
user={user!}
|
||||
/>
|
||||
|
||||
<SharedPreferences resourceUri="user" />
|
||||
<UserTeams isLoading={states.loadTeams} loadTeams={api.loadTeams} teams={teams} />
|
||||
<UserOrganizations
|
||||
isLoading={states.loadOrgs}
|
||||
setUserOrg={api.setUserOrg}
|
||||
loadOrgs={api.loadOrgs}
|
||||
orgs={orgs}
|
||||
user={user!}
|
||||
/>
|
||||
<UserSessions
|
||||
isLoading={states.loadSessions}
|
||||
loadSessions={api.loadSessions}
|
||||
revokeUserSession={api.revokeUserSession}
|
||||
sessions={sessions}
|
||||
user={user!}
|
||||
/>
|
||||
</VerticalGroup>
|
||||
)}
|
||||
</Page.Contents>
|
||||
);
|
||||
}}
|
||||
</UserProvider>
|
||||
</Page>
|
||||
);
|
||||
|
||||
function mapStateToProps(state: StoreState) {
|
||||
return {
|
||||
navModel: getNavModel(state.navIndex, 'profile-settings'),
|
||||
};
|
||||
}
|
||||
|
||||
export default hot(module)(connect(mapStateToProps, null)(UserProfileEdit));
|
||||
@@ -1,11 +1,11 @@
|
||||
import React, { FC } from 'react';
|
||||
import { Button, Tooltip, Icon, Form, Input, Field, FieldSet } from '@grafana/ui';
|
||||
import { Button, Field, FieldSet, Form, Icon, Input, Tooltip } from '@grafana/ui';
|
||||
import { UserDTO } from 'app/types';
|
||||
import config from 'app/core/config';
|
||||
import { ProfileUpdateFields } from 'app/core/utils/UserProvider';
|
||||
import { ProfileUpdateFields } from './types';
|
||||
|
||||
export interface Props {
|
||||
user: UserDTO;
|
||||
user: UserDTO | null;
|
||||
isSavingUser: boolean;
|
||||
updateProfile: (payload: ProfileUpdateFields) => void;
|
||||
}
|
||||
@@ -25,24 +25,32 @@ export const UserProfileEditForm: FC<Props> = ({ user, isSavingUser, updateProfi
|
||||
<Field label="Name" invalid={!!errors.name} error="Name is required" disabled={disableLoginForm}>
|
||||
<Input
|
||||
{...register('name', { required: true })}
|
||||
id="edit-user-profile-name"
|
||||
placeholder="Name"
|
||||
defaultValue={user.name}
|
||||
defaultValue={user?.name ?? ''}
|
||||
suffix={<InputSuffix />}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Email" invalid={!!errors.email} error="Email is required" disabled={disableLoginForm}>
|
||||
<Input
|
||||
{...register('email', { required: true })}
|
||||
id="edit-user-profile-email"
|
||||
placeholder="Email"
|
||||
defaultValue={user.email}
|
||||
defaultValue={user?.email ?? ''}
|
||||
suffix={<InputSuffix />}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Username" disabled={disableLoginForm}>
|
||||
<Input {...register('login')} defaultValue={user.login} placeholder="Username" suffix={<InputSuffix />} />
|
||||
<Input
|
||||
{...register('login')}
|
||||
id="edit-user-profile-username"
|
||||
defaultValue={user?.login ?? ''}
|
||||
placeholder="Username"
|
||||
suffix={<InputSuffix />}
|
||||
/>
|
||||
</Field>
|
||||
<div className="gf-form-button-row">
|
||||
<Button variant="primary" disabled={isSavingUser}>
|
||||
<Button variant="primary" disabled={isSavingUser} aria-label="Edit user profile save button">
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
import React from 'react';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { within } from '@testing-library/dom';
|
||||
import { OrgRole } from '@grafana/data';
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
|
||||
import { Props, UserProfileEditPage } from './UserProfileEditPage';
|
||||
import { initialUserState } from './state/reducers';
|
||||
import { getNavModel } from '../../core/selectors/navModel';
|
||||
import { backendSrv } from '../../core/services/backend_srv';
|
||||
import { TeamPermissionLevel } from '../../types';
|
||||
|
||||
const defaultProps: Props = {
|
||||
...initialUserState,
|
||||
user: {
|
||||
id: 1,
|
||||
name: 'Test User',
|
||||
email: 'test@test.com',
|
||||
login: 'test',
|
||||
isDisabled: false,
|
||||
isGrafanaAdmin: false,
|
||||
orgId: 0,
|
||||
},
|
||||
teams: [
|
||||
{
|
||||
id: 0,
|
||||
name: 'Team One',
|
||||
email: 'team.one@test.com',
|
||||
avatarUrl: '/avatar/07d881f402480a2a511a9a15b5fa82c0',
|
||||
memberCount: 2000,
|
||||
permission: TeamPermissionLevel.Admin,
|
||||
},
|
||||
],
|
||||
orgs: [
|
||||
{
|
||||
name: 'Main',
|
||||
orgId: 0,
|
||||
role: OrgRole.Editor,
|
||||
},
|
||||
{
|
||||
name: 'Second',
|
||||
orgId: 1,
|
||||
role: OrgRole.Viewer,
|
||||
},
|
||||
{
|
||||
name: 'Third',
|
||||
orgId: 2,
|
||||
role: OrgRole.Admin,
|
||||
},
|
||||
],
|
||||
sessions: [
|
||||
{
|
||||
id: 0,
|
||||
browser: 'Chrome',
|
||||
browserVersion: '90',
|
||||
clientIp: 'localhost',
|
||||
createdAt: '2021-01-01 04:00:00',
|
||||
device: 'Macbook Pro',
|
||||
isActive: true,
|
||||
os: 'Mac OS X',
|
||||
osVersion: '11',
|
||||
seenAt: new Date().toUTCString(),
|
||||
},
|
||||
],
|
||||
navModel: getNavModel(
|
||||
{
|
||||
'profile-settings': {
|
||||
icon: 'sliders-v-alt',
|
||||
id: 'profile-settings',
|
||||
parentItem: {
|
||||
id: 'profile',
|
||||
text: 'Test User',
|
||||
img: '/avatar/46d229b033af06a191ff2267bca9ae56',
|
||||
url: '/profile',
|
||||
},
|
||||
text: 'Preferences',
|
||||
url: '/profile',
|
||||
},
|
||||
},
|
||||
'profile-settings'
|
||||
),
|
||||
initUserProfilePage: jest.fn().mockResolvedValue(undefined),
|
||||
revokeUserSession: jest.fn().mockResolvedValue(undefined),
|
||||
changeUserOrg: jest.fn().mockResolvedValue(undefined),
|
||||
updateUserProfile: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
function getSelectors() {
|
||||
const dashboardSelect = () => screen.getByLabelText(/user preferences home dashboard drop down/i);
|
||||
const timepickerSelect = () => screen.getByLabelText(selectors.components.TimeZonePicker.container);
|
||||
const teamsTable = () => screen.getByRole('table', { name: /user teams table/i });
|
||||
const orgsTable = () => screen.getByRole('table', { name: /user organizations table/i });
|
||||
const sessionsTable = () => screen.getByRole('table', { name: /user sessions table/i });
|
||||
return {
|
||||
name: () => screen.getByRole('textbox', { name: /^name$/i }),
|
||||
email: () => screen.getByRole('textbox', { name: /email/i }),
|
||||
username: () => screen.getByRole('textbox', { name: /username/i }),
|
||||
saveProfile: () => screen.getByRole('button', { name: /edit user profile save button/i }),
|
||||
dashboardSelect,
|
||||
dashboardValue: () => within(dashboardSelect()).getByText(/default/i),
|
||||
timepickerSelect,
|
||||
timepickerValue: () => within(timepickerSelect()).getByText(/coordinated universal time/i),
|
||||
savePreferences: () => screen.getByRole('button', { name: /user preferences save button/i }),
|
||||
teamsTable,
|
||||
teamsRow: () => within(teamsTable()).getByRole('row', { name: /team one team.one@test\.com 2000/i }),
|
||||
orgsTable,
|
||||
orgsEditorRow: () => within(orgsTable()).getByRole('row', { name: /main editor current/i }),
|
||||
orgsViewerRow: () => within(orgsTable()).getByRole('row', { name: /second viewer select/i }),
|
||||
orgsAdminRow: () => within(orgsTable()).getByRole('row', { name: /third admin select/i }),
|
||||
sessionsTable,
|
||||
sessionsRow: () =>
|
||||
within(sessionsTable()).getByRole('row', {
|
||||
name: /now 2021-01-01 04:00:00 localhost chrome on mac os x 11/i,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
async function getTestContext(overrides: Partial<Props> = {}) {
|
||||
jest.clearAllMocks();
|
||||
const putSpy = jest.spyOn(backendSrv, 'put');
|
||||
const getSpy = jest
|
||||
.spyOn(backendSrv, 'get')
|
||||
.mockResolvedValue({ timezone: 'UTC', homeDashboardId: 0, theme: 'dark' });
|
||||
const searchSpy = jest.spyOn(backendSrv, 'search').mockResolvedValue([]);
|
||||
|
||||
const props = { ...defaultProps, ...overrides };
|
||||
const { rerender } = render(<UserProfileEditPage {...props} />);
|
||||
|
||||
await waitFor(() => expect(props.initUserProfilePage).toHaveBeenCalledTimes(1));
|
||||
|
||||
return { rerender, putSpy, getSpy, searchSpy, props };
|
||||
}
|
||||
|
||||
describe('UserProfileEditPage', () => {
|
||||
describe('when loading user', () => {
|
||||
it('should show loading placeholder', async () => {
|
||||
await getTestContext({ user: null });
|
||||
|
||||
expect(screen.getByText(/loading \.\.\./i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('when user has loaded', () => {
|
||||
it('should show edit profile form', async () => {
|
||||
await getTestContext();
|
||||
|
||||
const { name, email, username, saveProfile } = getSelectors();
|
||||
expect(screen.getByText(/edit profile/i)).toBeInTheDocument();
|
||||
expect(name()).toBeInTheDocument();
|
||||
expect(name()).toHaveValue('Test User');
|
||||
expect(email()).toBeInTheDocument();
|
||||
expect(email()).toHaveValue('test@test.com');
|
||||
expect(username()).toBeInTheDocument();
|
||||
expect(username()).toHaveValue('test');
|
||||
expect(saveProfile()).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show shared preferences', async () => {
|
||||
await getTestContext();
|
||||
|
||||
const { dashboardSelect, dashboardValue, timepickerSelect, timepickerValue, savePreferences } = getSelectors();
|
||||
expect(screen.getByRole('group', { name: /preferences/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('radio', { name: /default/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('radio', { name: /dark/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('radio', { name: /light/i })).toBeInTheDocument();
|
||||
expect(dashboardSelect()).toBeInTheDocument();
|
||||
expect(dashboardValue()).toBeInTheDocument();
|
||||
expect(timepickerSelect()).toBeInTheDocument();
|
||||
expect(timepickerValue()).toBeInTheDocument();
|
||||
expect(savePreferences()).toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe('and teams are loading', () => {
|
||||
it('should show teams loading placeholder', async () => {
|
||||
await getTestContext({ teamsAreLoading: true });
|
||||
|
||||
expect(screen.getByText(/loading teams\.\.\./i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('and teams are loaded', () => {
|
||||
it('should show teams', async () => {
|
||||
await getTestContext();
|
||||
|
||||
const { teamsTable, teamsRow } = getSelectors();
|
||||
expect(screen.getByRole('heading', { name: /teams/i })).toBeInTheDocument();
|
||||
expect(teamsTable()).toBeInTheDocument();
|
||||
expect(teamsRow()).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('and organizations are loading', () => {
|
||||
it('should show teams loading placeholder', async () => {
|
||||
await getTestContext({ orgsAreLoading: true });
|
||||
|
||||
expect(screen.getByText(/loading organizations\.\.\./i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('and organizations are loaded', () => {
|
||||
it('should show organizations', async () => {
|
||||
await getTestContext();
|
||||
|
||||
const { orgsTable, orgsEditorRow, orgsViewerRow, orgsAdminRow } = getSelectors();
|
||||
expect(screen.getByRole('heading', { name: /organizations/i })).toBeInTheDocument();
|
||||
expect(orgsTable()).toBeInTheDocument();
|
||||
expect(orgsEditorRow()).toBeInTheDocument();
|
||||
expect(orgsViewerRow()).toBeInTheDocument();
|
||||
expect(orgsAdminRow()).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('and sessions are loading', () => {
|
||||
it('should show teams loading placeholder', async () => {
|
||||
await getTestContext({ sessionsAreLoading: true });
|
||||
|
||||
expect(screen.getByText(/loading sessions\.\.\./i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('and sessions are loaded', () => {
|
||||
it('should show sessions', async () => {
|
||||
await getTestContext();
|
||||
|
||||
const { sessionsTable, sessionsRow } = getSelectors();
|
||||
expect(sessionsTable()).toBeInTheDocument();
|
||||
expect(sessionsRow()).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('and user is edited and saved', () => {
|
||||
it('should call updateUserProfile', async () => {
|
||||
const { props } = await getTestContext();
|
||||
|
||||
const { email, saveProfile } = getSelectors();
|
||||
userEvent.clear(email());
|
||||
await userEvent.type(email(), 'test@test.se');
|
||||
userEvent.click(saveProfile());
|
||||
|
||||
await waitFor(() => expect(props.updateUserProfile).toHaveBeenCalledTimes(1));
|
||||
expect(props.updateUserProfile).toHaveBeenCalledWith({
|
||||
email: 'test@test.se',
|
||||
login: 'test',
|
||||
name: 'Test User',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('and organization is changed', () => {
|
||||
it('should call changeUserOrg', async () => {
|
||||
const { props } = await getTestContext();
|
||||
const orgsAdminSelectButton = () =>
|
||||
within(getSelectors().orgsAdminRow()).getByRole('button', { name: /select/i });
|
||||
|
||||
userEvent.click(orgsAdminSelectButton());
|
||||
|
||||
await waitFor(() => expect(props.changeUserOrg).toHaveBeenCalledTimes(1));
|
||||
expect(props.changeUserOrg).toHaveBeenCalledWith({
|
||||
name: 'Third',
|
||||
orgId: 2,
|
||||
role: 'Admin',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('and session is revoked', () => {
|
||||
it('should call revokeUserSession', async () => {
|
||||
const { props } = await getTestContext();
|
||||
const sessionsRevokeButton = () => within(getSelectors().sessionsRow()).getByRole('button');
|
||||
|
||||
userEvent.click(sessionsRevokeButton());
|
||||
|
||||
await waitFor(() => expect(props.revokeUserSession).toHaveBeenCalledTimes(1));
|
||||
expect(props.revokeUserSession).toHaveBeenCalledWith(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
import React from 'react';
|
||||
import { connect, ConnectedProps } from 'react-redux';
|
||||
import { useMount } from 'react-use';
|
||||
import { hot } from 'react-hot-loader';
|
||||
import { NavModel } from '@grafana/data';
|
||||
import { VerticalGroup } from '@grafana/ui';
|
||||
|
||||
import { getNavModel } from 'app/core/selectors/navModel';
|
||||
import { StoreState } from 'app/types';
|
||||
import Page from 'app/core/components/Page/Page';
|
||||
import { changeUserOrg, initUserProfilePage, revokeUserSession, updateUserProfile } from './state/actions';
|
||||
import UserProfileEditForm from './UserProfileEditForm';
|
||||
import SharedPreferences from 'app/core/components/SharedPreferences/SharedPreferences';
|
||||
import { UserTeams } from './UserTeams';
|
||||
import UserOrganizations from './UserOrganizations';
|
||||
import UserSessions from './UserSessions';
|
||||
|
||||
export interface OwnProps {
|
||||
navModel: NavModel;
|
||||
}
|
||||
|
||||
function mapStateToProps(state: StoreState) {
|
||||
const userState = state.user;
|
||||
const { user, teams, orgs, sessions, teamsAreLoading, orgsAreLoading, sessionsAreLoading, isUpdating } = userState;
|
||||
return {
|
||||
navModel: getNavModel(state.navIndex, 'profile-settings'),
|
||||
orgsAreLoading,
|
||||
sessionsAreLoading,
|
||||
teamsAreLoading,
|
||||
orgs,
|
||||
sessions,
|
||||
teams,
|
||||
isUpdating,
|
||||
user,
|
||||
};
|
||||
}
|
||||
|
||||
const mapDispatchToProps = {
|
||||
initUserProfilePage,
|
||||
revokeUserSession,
|
||||
changeUserOrg,
|
||||
updateUserProfile,
|
||||
};
|
||||
|
||||
const connector = connect(mapStateToProps, mapDispatchToProps);
|
||||
|
||||
export type Props = OwnProps & ConnectedProps<typeof connector>;
|
||||
|
||||
export function UserProfileEditPage({
|
||||
navModel,
|
||||
orgsAreLoading,
|
||||
sessionsAreLoading,
|
||||
teamsAreLoading,
|
||||
initUserProfilePage,
|
||||
orgs,
|
||||
sessions,
|
||||
teams,
|
||||
isUpdating,
|
||||
user,
|
||||
revokeUserSession,
|
||||
changeUserOrg,
|
||||
updateUserProfile,
|
||||
}: Props) {
|
||||
useMount(() => initUserProfilePage());
|
||||
|
||||
return (
|
||||
<Page navModel={navModel}>
|
||||
<Page.Contents isLoading={!user}>
|
||||
<VerticalGroup spacing="md">
|
||||
<UserProfileEditForm updateProfile={updateUserProfile} isSavingUser={isUpdating} user={user} />
|
||||
<SharedPreferences resourceUri="user" />
|
||||
<UserTeams isLoading={teamsAreLoading} teams={teams} />
|
||||
<UserOrganizations isLoading={orgsAreLoading} setUserOrg={changeUserOrg} orgs={orgs} user={user} />
|
||||
<UserSessions isLoading={sessionsAreLoading} revokeUserSession={revokeUserSession} sessions={sessions} />
|
||||
</VerticalGroup>
|
||||
</Page.Contents>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
||||
export default hot(module)(connector(UserProfileEditPage));
|
||||
@@ -1,20 +1,14 @@
|
||||
import React, { PureComponent } from 'react';
|
||||
import { UserDTO, UserSession } from 'app/types';
|
||||
import { LoadingPlaceholder, Button, Icon } from '@grafana/ui';
|
||||
import { UserSession } from 'app/types';
|
||||
import { Button, Icon, LoadingPlaceholder } from '@grafana/ui';
|
||||
|
||||
export interface Props {
|
||||
user: UserDTO;
|
||||
sessions: UserSession[];
|
||||
isLoading: boolean;
|
||||
loadSessions: () => void;
|
||||
revokeUserSession: (tokenId: number) => void;
|
||||
}
|
||||
|
||||
export class UserSessions extends PureComponent<Props> {
|
||||
componentDidMount() {
|
||||
this.props.loadSessions();
|
||||
}
|
||||
|
||||
render() {
|
||||
const { isLoading, sessions, revokeUserSession } = this.props;
|
||||
|
||||
@@ -28,7 +22,7 @@ export class UserSessions extends PureComponent<Props> {
|
||||
<>
|
||||
<h3 className="page-sub-heading">Sessions</h3>
|
||||
<div className="gf-form-group">
|
||||
<table className="filter-table form-inline">
|
||||
<table className="filter-table form-inline" aria-label="User sessions table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Last seen</th>
|
||||
|
||||
@@ -5,14 +5,9 @@ import { LoadingPlaceholder } from '@grafana/ui';
|
||||
export interface Props {
|
||||
teams: Team[];
|
||||
isLoading: boolean;
|
||||
loadTeams: () => void;
|
||||
}
|
||||
|
||||
export class UserTeams extends PureComponent<Props> {
|
||||
componentDidMount() {
|
||||
this.props.loadTeams();
|
||||
}
|
||||
|
||||
render() {
|
||||
const { isLoading, teams } = this.props;
|
||||
|
||||
@@ -28,7 +23,7 @@ export class UserTeams extends PureComponent<Props> {
|
||||
<div>
|
||||
<h3 className="page-sub-heading">Teams</h3>
|
||||
<div className="gf-form-group">
|
||||
<table className="filter-table form-inline">
|
||||
<table className="filter-table form-inline" aria-label="User teams table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th />
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { getBackendSrv } from '@grafana/runtime';
|
||||
|
||||
import { ChangePasswordFields, ProfileUpdateFields } from './types';
|
||||
import { Team, UserDTO, UserOrg, UserSession } from '../../types';
|
||||
|
||||
async function changePassword(payload: ChangePasswordFields): Promise<void> {
|
||||
try {
|
||||
await getBackendSrv().put('/api/user/password', payload);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
|
||||
function loadUser(): Promise<UserDTO> {
|
||||
return getBackendSrv().get('/api/user');
|
||||
}
|
||||
|
||||
function loadTeams(): Promise<Team[]> {
|
||||
return getBackendSrv().get('/api/user/teams');
|
||||
}
|
||||
|
||||
function loadOrgs(): Promise<UserOrg[]> {
|
||||
return getBackendSrv().get('/api/user/orgs');
|
||||
}
|
||||
|
||||
function loadSessions(): Promise<UserSession[]> {
|
||||
return getBackendSrv().get('/api/user/auth-tokens');
|
||||
}
|
||||
|
||||
async function revokeUserSession(tokenId: number): Promise<void> {
|
||||
await getBackendSrv().post('/api/user/revoke-auth-token', {
|
||||
authTokenId: tokenId,
|
||||
});
|
||||
}
|
||||
|
||||
async function setUserOrg(org: UserOrg): Promise<void> {
|
||||
await getBackendSrv().post('/api/user/using/' + org.orgId, {});
|
||||
}
|
||||
|
||||
async function updateUserProfile(payload: ProfileUpdateFields): Promise<void> {
|
||||
try {
|
||||
await getBackendSrv().put('/api/user', payload);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
|
||||
export const api = {
|
||||
changePassword,
|
||||
revokeUserSession,
|
||||
loadUser,
|
||||
loadSessions,
|
||||
loadOrgs,
|
||||
loadTeams,
|
||||
setUserOrg,
|
||||
updateUserProfile,
|
||||
};
|
||||
@@ -0,0 +1,89 @@
|
||||
import { config } from '@grafana/runtime';
|
||||
|
||||
import { ChangePasswordFields, ProfileUpdateFields } from '../types';
|
||||
import { ThunkResult, UserOrg } from '../../../types';
|
||||
import {
|
||||
initLoadOrgs,
|
||||
initLoadSessions,
|
||||
initLoadTeams,
|
||||
orgsLoaded,
|
||||
sessionsLoaded,
|
||||
setUpdating,
|
||||
teamsLoaded,
|
||||
userLoaded,
|
||||
userSessionRevoked,
|
||||
} from './reducers';
|
||||
import { api } from '../api';
|
||||
|
||||
export function changePassword(payload: ChangePasswordFields): ThunkResult<void> {
|
||||
return async function (dispatch) {
|
||||
dispatch(setUpdating({ updating: true }));
|
||||
await api.changePassword(payload);
|
||||
dispatch(setUpdating({ updating: false }));
|
||||
};
|
||||
}
|
||||
|
||||
export function initUserProfilePage(): ThunkResult<void> {
|
||||
return async function (dispatch) {
|
||||
await dispatch(loadUser());
|
||||
dispatch(loadTeams());
|
||||
dispatch(loadOrgs());
|
||||
dispatch(loadSessions());
|
||||
};
|
||||
}
|
||||
|
||||
export function loadUser(): ThunkResult<void> {
|
||||
return async function (dispatch) {
|
||||
const user = await api.loadUser();
|
||||
dispatch(userLoaded({ user }));
|
||||
};
|
||||
}
|
||||
|
||||
function loadTeams(): ThunkResult<void> {
|
||||
return async function (dispatch) {
|
||||
dispatch(initLoadTeams());
|
||||
const teams = await api.loadTeams();
|
||||
dispatch(teamsLoaded({ teams }));
|
||||
};
|
||||
}
|
||||
|
||||
function loadOrgs(): ThunkResult<void> {
|
||||
return async function (dispatch) {
|
||||
dispatch(initLoadOrgs());
|
||||
const orgs = await api.loadOrgs();
|
||||
dispatch(orgsLoaded({ orgs }));
|
||||
};
|
||||
}
|
||||
|
||||
function loadSessions(): ThunkResult<void> {
|
||||
return async function (dispatch) {
|
||||
dispatch(initLoadSessions());
|
||||
const sessions = await api.loadSessions();
|
||||
dispatch(sessionsLoaded({ sessions }));
|
||||
};
|
||||
}
|
||||
|
||||
export function revokeUserSession(tokenId: number): ThunkResult<void> {
|
||||
return async function (dispatch) {
|
||||
dispatch(setUpdating({ updating: true }));
|
||||
await api.revokeUserSession(tokenId);
|
||||
dispatch(userSessionRevoked({ tokenId }));
|
||||
};
|
||||
}
|
||||
|
||||
export function changeUserOrg(org: UserOrg): ThunkResult<void> {
|
||||
return async function (dispatch) {
|
||||
dispatch(setUpdating({ updating: true }));
|
||||
await api.setUserOrg(org);
|
||||
window.location.href = config.appSubUrl + '/profile';
|
||||
};
|
||||
}
|
||||
|
||||
export function updateUserProfile(payload: ProfileUpdateFields): ThunkResult<void> {
|
||||
return async function (dispatch) {
|
||||
dispatch(setUpdating({ updating: true }));
|
||||
await api.updateUserProfile(payload);
|
||||
await dispatch(loadUser());
|
||||
dispatch(setUpdating({ updating: false }));
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import { reducerTester } from '../../../../test/core/redux/reducerTester';
|
||||
import { OrgRole, TeamPermissionLevel } from '../../../types';
|
||||
import {
|
||||
initialUserState,
|
||||
orgsLoaded,
|
||||
sessionsLoaded,
|
||||
setUpdating,
|
||||
teamsLoaded,
|
||||
updateTimeZone,
|
||||
userLoaded,
|
||||
userReducer,
|
||||
userSessionRevoked,
|
||||
UserState,
|
||||
} from './reducers';
|
||||
|
||||
describe('userReducer', () => {
|
||||
describe('when updateTimeZone is dispatched', () => {
|
||||
it('then state should be correct', () => {
|
||||
reducerTester<UserState>()
|
||||
.givenReducer(userReducer, { ...initialUserState })
|
||||
.whenActionIsDispatched(updateTimeZone({ timeZone: 'xyz' }))
|
||||
.thenStateShouldEqual({ ...initialUserState, timeZone: 'xyz' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('when setUpdating is dispatched', () => {
|
||||
it('then state should be correct', () => {
|
||||
reducerTester<UserState>()
|
||||
.givenReducer(userReducer, { ...initialUserState, isUpdating: false })
|
||||
.whenActionIsDispatched(setUpdating({ updating: true }))
|
||||
.thenStateShouldEqual({ ...initialUserState, isUpdating: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('when userLoaded is dispatched', () => {
|
||||
it('then state should be correct', () => {
|
||||
reducerTester<UserState>()
|
||||
.givenReducer(userReducer, { ...initialUserState, user: null })
|
||||
.whenActionIsDispatched(
|
||||
userLoaded({
|
||||
user: {
|
||||
id: 2021,
|
||||
email: 'test@test.com',
|
||||
isDisabled: true,
|
||||
login: 'test',
|
||||
name: 'Test Account',
|
||||
isGrafanaAdmin: false,
|
||||
},
|
||||
})
|
||||
)
|
||||
.thenStateShouldEqual({
|
||||
...initialUserState,
|
||||
user: {
|
||||
id: 2021,
|
||||
email: 'test@test.com',
|
||||
isDisabled: true,
|
||||
login: 'test',
|
||||
name: 'Test Account',
|
||||
isGrafanaAdmin: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when teamsLoaded is dispatched', () => {
|
||||
it('then state should be correct', () => {
|
||||
reducerTester<UserState>()
|
||||
.givenReducer(userReducer, { ...initialUserState, teamsAreLoading: true })
|
||||
.whenActionIsDispatched(
|
||||
teamsLoaded({
|
||||
teams: [
|
||||
{
|
||||
id: 1,
|
||||
email: 'team@team.com',
|
||||
name: 'Team',
|
||||
avatarUrl: '/avatar/12345',
|
||||
memberCount: 4,
|
||||
permission: TeamPermissionLevel.Admin,
|
||||
},
|
||||
],
|
||||
})
|
||||
)
|
||||
.thenStateShouldEqual({
|
||||
...initialUserState,
|
||||
teamsAreLoading: false,
|
||||
teams: [
|
||||
{
|
||||
id: 1,
|
||||
email: 'team@team.com',
|
||||
name: 'Team',
|
||||
avatarUrl: '/avatar/12345',
|
||||
memberCount: 4,
|
||||
permission: TeamPermissionLevel.Admin,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when orgsLoaded is dispatched', () => {
|
||||
it('then state should be correct', () => {
|
||||
reducerTester<UserState>()
|
||||
.givenReducer(userReducer, { ...initialUserState, orgsAreLoading: true })
|
||||
.whenActionIsDispatched(
|
||||
orgsLoaded({
|
||||
orgs: [{ orgId: 1, name: 'Main', role: OrgRole.Viewer }],
|
||||
})
|
||||
)
|
||||
.thenStateShouldEqual({
|
||||
...initialUserState,
|
||||
orgsAreLoading: false,
|
||||
orgs: [{ orgId: 1, name: 'Main', role: OrgRole.Viewer }],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when sessionsLoaded is dispatched', () => {
|
||||
it('then state should be correct', () => {
|
||||
reducerTester<UserState>()
|
||||
.givenReducer(userReducer, { ...initialUserState, sessionsAreLoading: true })
|
||||
.whenActionIsDispatched(
|
||||
sessionsLoaded({
|
||||
sessions: [
|
||||
{
|
||||
id: 1,
|
||||
browser: 'Chrome',
|
||||
browserVersion: '90',
|
||||
osVersion: '95',
|
||||
clientIp: '192.168.1.1',
|
||||
createdAt: '2021-01-01 04:00:00',
|
||||
device: 'Computer',
|
||||
os: 'Windows',
|
||||
isActive: false,
|
||||
seenAt: '1996-01-01 04:00:00',
|
||||
},
|
||||
],
|
||||
})
|
||||
)
|
||||
.thenStateShouldEqual({
|
||||
...initialUserState,
|
||||
sessionsAreLoading: false,
|
||||
sessions: [
|
||||
{
|
||||
id: 1,
|
||||
browser: 'Chrome',
|
||||
browserVersion: '90',
|
||||
osVersion: '95',
|
||||
clientIp: '192.168.1.1',
|
||||
createdAt: 'December 31, 2020',
|
||||
device: 'Computer',
|
||||
os: 'Windows',
|
||||
isActive: false,
|
||||
seenAt: '25 years ago',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when userSessionRevoked is dispatched', () => {
|
||||
it('then state should be correct', () => {
|
||||
reducerTester<UserState>()
|
||||
.givenReducer(userReducer, {
|
||||
...initialUserState,
|
||||
sessions: [
|
||||
{
|
||||
id: 1,
|
||||
browser: 'Chrome',
|
||||
browserVersion: '90',
|
||||
osVersion: '95',
|
||||
clientIp: '192.168.1.1',
|
||||
createdAt: '2021-01-01',
|
||||
device: 'Computer',
|
||||
os: 'Windows',
|
||||
isActive: false,
|
||||
seenAt: '1996-01-01',
|
||||
},
|
||||
],
|
||||
})
|
||||
.whenActionIsDispatched(userSessionRevoked({ tokenId: 1 }))
|
||||
.thenStateShouldEqual({
|
||||
...initialUserState,
|
||||
sessions: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,40 +1,115 @@
|
||||
import { isString, isEmpty, set } from 'lodash';
|
||||
import { PayloadAction, createSlice } from '@reduxjs/toolkit';
|
||||
import { UserState, ThunkResult } from 'app/types';
|
||||
import { isEmpty, isString, set } from 'lodash';
|
||||
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||
import { dateTimeFormat, dateTimeFormatTimeAgo, TimeZone } from '@grafana/data';
|
||||
|
||||
import { Team, ThunkResult, UserDTO, UserOrg, UserSession } from 'app/types';
|
||||
import config from 'app/core/config';
|
||||
import { TimeZone } from '@grafana/data';
|
||||
import { contextSrv } from 'app/core/core';
|
||||
|
||||
export const initialState: UserState = {
|
||||
export interface UserState {
|
||||
orgId: number;
|
||||
timeZone: TimeZone;
|
||||
user: UserDTO | null;
|
||||
teams: Team[];
|
||||
orgs: UserOrg[];
|
||||
sessions: UserSession[];
|
||||
teamsAreLoading: boolean;
|
||||
orgsAreLoading: boolean;
|
||||
sessionsAreLoading: boolean;
|
||||
isUpdating: boolean;
|
||||
}
|
||||
|
||||
export const initialUserState: UserState = {
|
||||
orgId: config.bootData.user.orgId,
|
||||
timeZone: config.bootData.user.timezone,
|
||||
orgsAreLoading: false,
|
||||
sessionsAreLoading: false,
|
||||
teamsAreLoading: false,
|
||||
isUpdating: false,
|
||||
orgs: [],
|
||||
sessions: [],
|
||||
teams: [],
|
||||
user: null,
|
||||
};
|
||||
|
||||
export const slice = createSlice({
|
||||
name: 'user/profile',
|
||||
initialState,
|
||||
initialState: initialUserState,
|
||||
reducers: {
|
||||
updateTimeZone: (state, action: PayloadAction<TimeZone>): UserState => {
|
||||
return {
|
||||
...state,
|
||||
timeZone: action.payload,
|
||||
};
|
||||
updateTimeZone: (state, action: PayloadAction<{ timeZone: TimeZone }>) => {
|
||||
state.timeZone = action.payload.timeZone;
|
||||
},
|
||||
setUpdating: (state, action: PayloadAction<{ updating: boolean }>) => {
|
||||
state.isUpdating = action.payload.updating;
|
||||
},
|
||||
userLoaded: (state, action: PayloadAction<{ user: UserDTO }>) => {
|
||||
state.user = action.payload.user;
|
||||
},
|
||||
initLoadTeams: (state, action: PayloadAction<undefined>) => {
|
||||
state.teamsAreLoading = true;
|
||||
},
|
||||
teamsLoaded: (state, action: PayloadAction<{ teams: Team[] }>) => {
|
||||
state.teams = action.payload.teams;
|
||||
state.teamsAreLoading = false;
|
||||
},
|
||||
initLoadOrgs: (state, action: PayloadAction<undefined>) => {
|
||||
state.orgsAreLoading = true;
|
||||
},
|
||||
orgsLoaded: (state, action: PayloadAction<{ orgs: UserOrg[] }>) => {
|
||||
state.orgs = action.payload.orgs;
|
||||
state.orgsAreLoading = false;
|
||||
},
|
||||
initLoadSessions: (state, action: PayloadAction<undefined>) => {
|
||||
state.sessionsAreLoading = true;
|
||||
},
|
||||
sessionsLoaded: (state, action: PayloadAction<{ sessions: UserSession[] }>) => {
|
||||
const sorted = action.payload.sessions.sort((a, b) => Number(b.isActive) - Number(a.isActive)); // Show active sessions first
|
||||
state.sessions = sorted.map((session) => ({
|
||||
id: session.id,
|
||||
isActive: session.isActive,
|
||||
seenAt: dateTimeFormatTimeAgo(session.seenAt),
|
||||
createdAt: dateTimeFormat(session.createdAt, { format: 'MMMM DD, YYYY' }),
|
||||
clientIp: session.clientIp,
|
||||
browser: session.browser,
|
||||
browserVersion: session.browserVersion,
|
||||
os: session.os,
|
||||
osVersion: session.osVersion,
|
||||
device: session.device,
|
||||
}));
|
||||
state.sessionsAreLoading = false;
|
||||
},
|
||||
userSessionRevoked: (state, action: PayloadAction<{ tokenId: number }>) => {
|
||||
state.sessions = state.sessions.filter((session: UserSession) => {
|
||||
return session.id !== action.payload.tokenId;
|
||||
});
|
||||
state.isUpdating = false;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const updateTimeZoneForSession = (timeZone: TimeZone): ThunkResult<void> => {
|
||||
return async (dispatch) => {
|
||||
const { updateTimeZone } = slice.actions;
|
||||
|
||||
if (!isString(timeZone) || isEmpty(timeZone)) {
|
||||
timeZone = config?.bootData?.user?.timezone;
|
||||
}
|
||||
|
||||
set(contextSrv, 'user.timezone', timeZone);
|
||||
dispatch(updateTimeZone(timeZone));
|
||||
dispatch(updateTimeZone({ timeZone }));
|
||||
};
|
||||
};
|
||||
|
||||
export const {
|
||||
setUpdating,
|
||||
initLoadOrgs,
|
||||
orgsLoaded,
|
||||
initLoadTeams,
|
||||
teamsLoaded,
|
||||
userLoaded,
|
||||
userSessionRevoked,
|
||||
initLoadSessions,
|
||||
sessionsLoaded,
|
||||
updateTimeZone,
|
||||
} = slice.actions;
|
||||
|
||||
export const userReducer = slice.reducer;
|
||||
export default { user: slice.reducer };
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
import { UserState } from 'app/types';
|
||||
import { UserState } from './reducers';
|
||||
|
||||
export const getTimeZone = (state: UserState) => state.timeZone;
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
export interface ChangePasswordFields {
|
||||
oldPassword: string;
|
||||
newPassword: string;
|
||||
confirmNew: string;
|
||||
}
|
||||
|
||||
export interface ProfileUpdateFields {
|
||||
name: string;
|
||||
email: string;
|
||||
login: string;
|
||||
}
|
||||
Reference in New Issue
Block a user