diff --git a/public/app/core/components/SharedPreferences/SharedPreferences.tsx b/public/app/core/components/SharedPreferences/SharedPreferences.tsx index 9b06fef9b9f..f717996f837 100644 --- a/public/app/core/components/SharedPreferences/SharedPreferences.tsx +++ b/public/app/core/components/SharedPreferences/SharedPreferences.tsx @@ -2,17 +2,17 @@ import React, { PureComponent } from 'react'; import { css } from '@emotion/css'; import { - Select, - Field, - Form, - Tooltip, - Icon, - stylesFactory, - Label, Button, - RadioButtonGroup, + Field, FieldSet, + Form, + Icon, + Label, + RadioButtonGroup, + Select, + stylesFactory, TimeZonePicker, + Tooltip, } from '@grafana/ui'; import { SelectableValue } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; @@ -142,6 +142,7 @@ export class SharedPreferences extends PureComponent { } + aria-label="User preferences home dashboard drop down" > } /> } /> - } /> + } + />
-
diff --git a/public/app/features/profile/UserProfileEditPage.test.tsx b/public/app/features/profile/UserProfileEditPage.test.tsx new file mode 100644 index 00000000000..085a2a67301 --- /dev/null +++ b/public/app/features/profile/UserProfileEditPage.test.tsx @@ -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 = {}) { + 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(); + + 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); + }); + }); + }); +}); diff --git a/public/app/features/profile/UserProfileEditPage.tsx b/public/app/features/profile/UserProfileEditPage.tsx new file mode 100644 index 00000000000..8ffdf34a7b7 --- /dev/null +++ b/public/app/features/profile/UserProfileEditPage.tsx @@ -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; + +export function UserProfileEditPage({ + navModel, + orgsAreLoading, + sessionsAreLoading, + teamsAreLoading, + initUserProfilePage, + orgs, + sessions, + teams, + isUpdating, + user, + revokeUserSession, + changeUserOrg, + updateUserProfile, +}: Props) { + useMount(() => initUserProfilePage()); + + return ( + + + + + + + + + + + + ); +} + +export default hot(module)(connector(UserProfileEditPage)); diff --git a/public/app/features/profile/UserSessions.tsx b/public/app/features/profile/UserSessions.tsx index 545a0dd5b46..ac5f55a6135 100644 --- a/public/app/features/profile/UserSessions.tsx +++ b/public/app/features/profile/UserSessions.tsx @@ -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 { - componentDidMount() { - this.props.loadSessions(); - } - render() { const { isLoading, sessions, revokeUserSession } = this.props; @@ -28,7 +22,7 @@ export class UserSessions extends PureComponent { <>

Sessions

- +
diff --git a/public/app/features/profile/UserTeams.tsx b/public/app/features/profile/UserTeams.tsx index c9e2ffc4a88..61c701ae259 100644 --- a/public/app/features/profile/UserTeams.tsx +++ b/public/app/features/profile/UserTeams.tsx @@ -5,14 +5,9 @@ import { LoadingPlaceholder } from '@grafana/ui'; export interface Props { teams: Team[]; isLoading: boolean; - loadTeams: () => void; } export class UserTeams extends PureComponent { - componentDidMount() { - this.props.loadTeams(); - } - render() { const { isLoading, teams } = this.props; @@ -28,7 +23,7 @@ export class UserTeams extends PureComponent {

Teams

-
Last seen
+
diff --git a/public/app/features/profile/api.ts b/public/app/features/profile/api.ts new file mode 100644 index 00000000000..7adc848a5ec --- /dev/null +++ b/public/app/features/profile/api.ts @@ -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 { + try { + await getBackendSrv().put('/api/user/password', payload); + } catch (err) { + console.error(err); + } +} + +function loadUser(): Promise { + return getBackendSrv().get('/api/user'); +} + +function loadTeams(): Promise { + return getBackendSrv().get('/api/user/teams'); +} + +function loadOrgs(): Promise { + return getBackendSrv().get('/api/user/orgs'); +} + +function loadSessions(): Promise { + return getBackendSrv().get('/api/user/auth-tokens'); +} + +async function revokeUserSession(tokenId: number): Promise { + await getBackendSrv().post('/api/user/revoke-auth-token', { + authTokenId: tokenId, + }); +} + +async function setUserOrg(org: UserOrg): Promise { + await getBackendSrv().post('/api/user/using/' + org.orgId, {}); +} + +async function updateUserProfile(payload: ProfileUpdateFields): Promise { + try { + await getBackendSrv().put('/api/user', payload); + } catch (err) { + console.error(err); + } +} + +export const api = { + changePassword, + revokeUserSession, + loadUser, + loadSessions, + loadOrgs, + loadTeams, + setUserOrg, + updateUserProfile, +}; diff --git a/public/app/features/profile/state/actions.ts b/public/app/features/profile/state/actions.ts new file mode 100644 index 00000000000..2155b1088b2 --- /dev/null +++ b/public/app/features/profile/state/actions.ts @@ -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 { + return async function (dispatch) { + dispatch(setUpdating({ updating: true })); + await api.changePassword(payload); + dispatch(setUpdating({ updating: false })); + }; +} + +export function initUserProfilePage(): ThunkResult { + return async function (dispatch) { + await dispatch(loadUser()); + dispatch(loadTeams()); + dispatch(loadOrgs()); + dispatch(loadSessions()); + }; +} + +export function loadUser(): ThunkResult { + return async function (dispatch) { + const user = await api.loadUser(); + dispatch(userLoaded({ user })); + }; +} + +function loadTeams(): ThunkResult { + return async function (dispatch) { + dispatch(initLoadTeams()); + const teams = await api.loadTeams(); + dispatch(teamsLoaded({ teams })); + }; +} + +function loadOrgs(): ThunkResult { + return async function (dispatch) { + dispatch(initLoadOrgs()); + const orgs = await api.loadOrgs(); + dispatch(orgsLoaded({ orgs })); + }; +} + +function loadSessions(): ThunkResult { + return async function (dispatch) { + dispatch(initLoadSessions()); + const sessions = await api.loadSessions(); + dispatch(sessionsLoaded({ sessions })); + }; +} + +export function revokeUserSession(tokenId: number): ThunkResult { + return async function (dispatch) { + dispatch(setUpdating({ updating: true })); + await api.revokeUserSession(tokenId); + dispatch(userSessionRevoked({ tokenId })); + }; +} + +export function changeUserOrg(org: UserOrg): ThunkResult { + return async function (dispatch) { + dispatch(setUpdating({ updating: true })); + await api.setUserOrg(org); + window.location.href = config.appSubUrl + '/profile'; + }; +} + +export function updateUserProfile(payload: ProfileUpdateFields): ThunkResult { + return async function (dispatch) { + dispatch(setUpdating({ updating: true })); + await api.updateUserProfile(payload); + await dispatch(loadUser()); + dispatch(setUpdating({ updating: false })); + }; +} diff --git a/public/app/features/profile/state/reducers.test.ts b/public/app/features/profile/state/reducers.test.ts new file mode 100644 index 00000000000..2bccd7434a6 --- /dev/null +++ b/public/app/features/profile/state/reducers.test.ts @@ -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() + .givenReducer(userReducer, { ...initialUserState }) + .whenActionIsDispatched(updateTimeZone({ timeZone: 'xyz' })) + .thenStateShouldEqual({ ...initialUserState, timeZone: 'xyz' }); + }); + }); + + describe('when setUpdating is dispatched', () => { + it('then state should be correct', () => { + reducerTester() + .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() + .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() + .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() + .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() + .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() + .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: [], + }); + }); + }); +}); diff --git a/public/app/features/profile/state/reducers.ts b/public/app/features/profile/state/reducers.ts index 60571e4f711..111fa1fc3ed 100644 --- a/public/app/features/profile/state/reducers.ts +++ b/public/app/features/profile/state/reducers.ts @@ -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): 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) => { + state.teamsAreLoading = true; + }, + teamsLoaded: (state, action: PayloadAction<{ teams: Team[] }>) => { + state.teams = action.payload.teams; + state.teamsAreLoading = false; + }, + initLoadOrgs: (state, action: PayloadAction) => { + state.orgsAreLoading = true; + }, + orgsLoaded: (state, action: PayloadAction<{ orgs: UserOrg[] }>) => { + state.orgs = action.payload.orgs; + state.orgsAreLoading = false; + }, + initLoadSessions: (state, action: PayloadAction) => { + 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 => { 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 }; diff --git a/public/app/features/profile/state/selectors.ts b/public/app/features/profile/state/selectors.ts index d6d53dade9d..cd984b6a6c6 100644 --- a/public/app/features/profile/state/selectors.ts +++ b/public/app/features/profile/state/selectors.ts @@ -1,3 +1,3 @@ -import { UserState } from 'app/types'; +import { UserState } from './reducers'; export const getTimeZone = (state: UserState) => state.timeZone; diff --git a/public/app/features/profile/types.ts b/public/app/features/profile/types.ts new file mode 100644 index 00000000000..c5692e188b9 --- /dev/null +++ b/public/app/features/profile/types.ts @@ -0,0 +1,11 @@ +export interface ChangePasswordFields { + oldPassword: string; + newPassword: string; + confirmNew: string; +} + +export interface ProfileUpdateFields { + name: string; + email: string; + login: string; +} diff --git a/public/app/routes/routes.tsx b/public/app/routes/routes.tsx index 9520220e7e1..253dfa7e49a 100644 --- a/public/app/routes/routes.tsx +++ b/public/app/routes/routes.tsx @@ -203,7 +203,7 @@ export function getAppRoutes(): RouteDescriptor[] { { path: '/profile', component: SafeDynamicImport( - () => import(/* webpackChunkName: "UserProfileEdit" */ 'app/features/profile/UserProfileEdit') + () => import(/* webpackChunkName: "UserProfileEditPage" */ 'app/features/profile/UserProfileEditPage') ), }, { diff --git a/public/app/types/store.ts b/public/app/types/store.ts index 55258e032d2..d793a622b46 100644 --- a/public/app/types/store.ts +++ b/public/app/types/store.ts @@ -8,7 +8,7 @@ import { FolderState } from './folders'; import { DashboardState } from './dashboard'; import { DataSourceSettingsState, DataSourcesState } from './datasources'; import { ExploreState } from './explore'; -import { UserAdminState, UserListAdminState, UsersState, UserState } from './user'; +import { UserAdminState, UserListAdminState, UsersState } from './user'; import { OrganizationState } from './organization'; import { AppNotificationsState } from './appNotifications'; import { PluginsState } from './plugins'; @@ -17,6 +17,7 @@ import { PanelEditorState } from '../features/dashboard/components/PanelEditor/s import { ApiKeysState } from './apiKeys'; import { TemplatingState } from '../features/variables/state/reducers'; import { ImportDashboardState } from '../features/manage-dashboards/state/reducers'; +import { UserState } from 'app/features/profile/state/reducers'; export interface StoreState { navIndex: NavIndex; diff --git a/public/app/types/user.ts b/public/app/types/user.ts index 076b5977182..0113e7dbb1a 100644 --- a/public/app/types/user.ts +++ b/public/app/types/user.ts @@ -1,4 +1,3 @@ -import { TimeZone } from '@grafana/data'; import { OrgRole } from '.'; export interface OrgUser { @@ -69,11 +68,6 @@ export interface UsersState { hasFetched: boolean; } -export interface UserState { - orgId: number; - timeZone: TimeZone; -} - export interface UserSession { id: number; createdAt: string;