diff --git a/packages/grafana-ui/src/components/Button/Button.story.tsx b/packages/grafana-ui/src/components/Button/Button.story.tsx index 149dd284499..db67db47a77 100644 --- a/packages/grafana-ui/src/components/Button/Button.story.tsx +++ b/packages/grafana-ui/src/components/Button/Button.story.tsx @@ -16,7 +16,7 @@ const defaultProps = { const variants = { size: ['xs', 'sm', 'md', 'lg'], - variant: ['primary', 'secondary', 'danger', 'inverse', 'transparent'], + variant: ['primary', 'secondary', 'danger', 'inverse', 'transparent', 'link'], }; const combinationOptions = { CombinationRenderer: ThemeableCombinationsRowRenderer, diff --git a/packages/grafana-ui/src/components/Button/styles.ts b/packages/grafana-ui/src/components/Button/styles.ts index a94fa82012f..43a6ae85c44 100644 --- a/packages/grafana-ui/src/components/Button/styles.ts +++ b/packages/grafana-ui/src/components/Button/styles.ts @@ -67,6 +67,13 @@ export const getButtonStyles = stylesFactory(({ theme, size, variant, textAndIco background: transparent; `; break; + + case 'link': + background = css` + ${buttonVariantStyles('', '', theme.colors.linkExternal, 'rgba(0, 0, 0, 0.1)', true)}; + background: transparent; + `; + break; } return { diff --git a/packages/grafana-ui/src/components/ConfirmButton/ConfirmButton.tsx b/packages/grafana-ui/src/components/ConfirmButton/ConfirmButton.tsx index 5963d3024ee..04aefac91b9 100644 --- a/packages/grafana-ui/src/components/ConfirmButton/ConfirmButton.tsx +++ b/packages/grafana-ui/src/components/ConfirmButton/ConfirmButton.tsx @@ -133,9 +133,11 @@ class UnThemedConfirmButton extends PureComponent { return ( {typeof children === 'string' ? ( - - {children} - + + + {children} + + ) : ( {children} diff --git a/packages/grafana-ui/src/components/Forms/Button.tsx b/packages/grafana-ui/src/components/Forms/Button.tsx index df6f7a0f5b5..4a2e0baddc8 100644 --- a/packages/grafana-ui/src/components/Forms/Button.tsx +++ b/packages/grafana-ui/src/components/Forms/Button.tsx @@ -38,7 +38,7 @@ const getPropertiesForVariant = (theme: GrafanaTheme, variant: ButtonVariant) => ) as string; return { - borderColor: selectThemeVariant({ light: theme.colors.gray70, dark: theme.colors.gray33 }, theme.type), + borderColor: selectThemeVariant({ light: theme.colors.gray85, dark: theme.colors.gray25 }, theme.type), background: buttonVariantStyles( from, to, @@ -57,7 +57,6 @@ const getPropertiesForVariant = (theme: GrafanaTheme, variant: ButtonVariant) => borderColor: 'transparent', background: buttonVariantStyles('transparent', 'transparent', theme.colors.linkExternal), variantStyles: css` - text-decoration: underline; &:focus { outline: none; box-shadow: none; diff --git a/packages/grafana-ui/src/components/Input/Input.tsx b/packages/grafana-ui/src/components/Input/Input.tsx index 95cc342c479..3d1870ccda4 100644 --- a/packages/grafana-ui/src/components/Input/Input.tsx +++ b/packages/grafana-ui/src/components/Input/Input.tsx @@ -11,6 +11,7 @@ export enum InputStatus { interface Props extends React.HTMLProps { validationEvents?: ValidationEvents; hideErrorMessage?: boolean; + inputRef?: React.LegacyRef; // Override event props and append status as argument onBlur?: (event: React.FocusEvent, status?: InputStatus) => void; @@ -70,14 +71,14 @@ export class Input extends PureComponent { }; render() { - const { validationEvents, className, hideErrorMessage, ...restProps } = this.props; + const { validationEvents, className, hideErrorMessage, inputRef, ...restProps } = this.props; const { error } = this.state; const inputClassName = classNames('gf-form-input', { invalid: this.isInvalid }, className); const inputElementProps = this.populateEventPropsWithStatus(restProps, validationEvents); return (
- + {error && !hideErrorMessage && {error}}
); diff --git a/pkg/api/user.go b/pkg/api/user.go index 5548eafeab9..8b06dfb322f 100644 --- a/pkg/api/user.go +++ b/pkg/api/user.go @@ -36,6 +36,8 @@ func getUserUserProfile(userID int64) Response { query.Result.IsExternal = true } + query.Result.AvatarUrl = dtos.GetGravatarUrl(query.Result.Email) + return JSON(200, query.Result) } diff --git a/pkg/api/user_test.go b/pkg/api/user_test.go index 31e2d81020b..6e95c581e02 100644 --- a/pkg/api/user_test.go +++ b/pkg/api/user_test.go @@ -1,10 +1,12 @@ package api import ( + "fmt" "net/http" "testing" "time" + "github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/models" @@ -48,9 +50,10 @@ func TestUserApiEndpoint(t *testing.T) { }) sc.handlerFunc = GetUserByID + avatarUrl := dtos.GetGravatarUrl("daniel@grafana.com") sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec() - expected := ` + expected := fmt.Sprintf(` { "id": 1, "email": "daniel@grafana.com", @@ -64,10 +67,11 @@ func TestUserApiEndpoint(t *testing.T) { "authLabels": [ "LDAP" ], + "avatarUrl": "%s", "updatedAt": "2019-02-11T17:30:40Z", "createdAt": "2019-02-11T17:30:40Z" } - ` + `, avatarUrl) require.Equal(t, http.StatusOK, sc.resp.Code) require.JSONEq(t, expected, sc.resp.Body.String()) @@ -109,6 +113,7 @@ func TestUserApiEndpoint(t *testing.T) { "isDisabled": false, "authLabels": null, "isExternal": false, + "avatarUrl": "", "updatedAt": "2019-02-11T17:30:40Z", "createdAt": "2019-02-11T17:30:40Z" } diff --git a/pkg/models/user.go b/pkg/models/user.go index 39ab0136ca5..3cf9a96334e 100644 --- a/pkg/models/user.go +++ b/pkg/models/user.go @@ -227,6 +227,7 @@ type UserProfileDTO struct { AuthLabels []string `json:"authLabels"` UpdatedAt time.Time `json:"updatedAt"` CreatedAt time.Time `json:"createdAt"` + AvatarUrl string `json:"avatarUrl"` } type UserSearchHitDTO struct { diff --git a/public/app/core/components/Select/OrgPicker.tsx b/public/app/core/components/Select/OrgPicker.tsx new file mode 100644 index 00000000000..d9c40217e81 --- /dev/null +++ b/public/app/core/components/Select/OrgPicker.tsx @@ -0,0 +1,71 @@ +import React, { PureComponent } from 'react'; +import { AsyncSelect } from '@grafana/ui'; +import { getBackendSrv } from 'app/core/services/backend_srv'; +import { Organization } from 'app/types'; +import { SelectableValue } from '@grafana/data'; + +export interface OrgSelectItem { + id: number; + value: number; + label: string; + name: string; +} + +export interface Props { + onSelected: (org: OrgSelectItem) => void; + className?: string; +} + +export interface State { + isLoading: boolean; +} + +export class OrgPicker extends PureComponent { + orgs: Organization[]; + + state: State = { + isLoading: false, + }; + + async loadOrgs() { + this.setState({ isLoading: true }); + const orgs = await getBackendSrv().get('/api/orgs'); + this.orgs = orgs; + this.setState({ isLoading: false }); + return orgs; + } + + getOrgOptions = async (query: string): Promise>> => { + if (!this.orgs) { + await this.loadOrgs(); + } + return this.orgs.map( + (org: Organization): SelectableValue => ({ + id: org.id, + value: org.id, + label: org.name, + name: org.name, + }) + ); + }; + + render() { + const { className, onSelected } = this.props; + const { isLoading } = this.state; + + return ( +
+ 'No organizations found'} + /> +
+ ); + } +} diff --git a/public/app/features/admin/DisabledUserInfo.tsx b/public/app/features/admin/DisabledUserInfo.tsx deleted file mode 100644 index 4b7000b7980..00000000000 --- a/public/app/features/admin/DisabledUserInfo.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import React, { FC } from 'react'; -import { UserInfo } from './UserInfo'; -import { LdapUserPermissions } from './ldap/LdapUserPermissions'; -import { User } from 'app/types'; - -interface Props { - user: User; -} - -export const DisabledUserInfo: FC = ({ user }) => { - return ( - <> - - - - ); -}; diff --git a/public/app/features/admin/UserAdminPage.tsx b/public/app/features/admin/UserAdminPage.tsx new file mode 100644 index 00000000000..d8ef4539264 --- /dev/null +++ b/public/app/features/admin/UserAdminPage.tsx @@ -0,0 +1,200 @@ +import React, { PureComponent } from 'react'; +import { hot } from 'react-hot-loader'; +import { connect } from 'react-redux'; +import { NavModel } from '@grafana/data'; +import { getNavModel } from 'app/core/selectors/navModel'; +import { getRouteParamsId } from 'app/core/selectors/location'; +import config from 'app/core/config'; +import Page from 'app/core/components/Page/Page'; +import { UserProfile } from './UserProfile'; +import { UserPermissions } from './UserPermissions'; +import { UserSessions } from './UserSessions'; +import { UserLdapSyncInfo } from './UserLdapSyncInfo'; +import { StoreState, UserDTO, UserOrg, UserSession, SyncInfo, UserAdminError } from 'app/types'; +import { + loadAdminUserPage, + revokeSession, + revokeAllSessions, + updateUser, + setUserPassword, + disableUser, + enableUser, + deleteUser, + updateUserPermissions, + addOrgUser, + updateOrgUserRole, + deleteOrgUser, + syncLdapUser, +} from './state/actions'; +import { UserOrgs } from './UserOrgs'; + +interface Props { + navModel: NavModel; + userId: number; + user: UserDTO; + orgs: UserOrg[]; + sessions: UserSession[]; + ldapSyncInfo: SyncInfo; + isLoading: boolean; + error: UserAdminError; + + loadAdminUserPage: typeof loadAdminUserPage; + revokeSession: typeof revokeSession; + revokeAllSessions: typeof revokeAllSessions; + updateUser: typeof updateUser; + setUserPassword: typeof setUserPassword; + disableUser: typeof disableUser; + enableUser: typeof enableUser; + deleteUser: typeof deleteUser; + updateUserPermissions: typeof updateUserPermissions; + addOrgUser: typeof addOrgUser; + updateOrgUserRole: typeof updateOrgUserRole; + deleteOrgUser: typeof deleteOrgUser; + syncLdapUser: typeof syncLdapUser; +} + +interface State { + // isLoading: boolean; +} + +export class UserAdminPage extends PureComponent { + state = { + // isLoading: true, + }; + + async componentDidMount() { + const { userId, loadAdminUserPage } = this.props; + loadAdminUserPage(userId); + } + + onUserUpdate = (user: UserDTO) => { + this.props.updateUser(user); + }; + + onPasswordChange = (password: string) => { + const { userId, setUserPassword } = this.props; + setUserPassword(userId, password); + }; + + onUserDelete = (userId: number) => { + this.props.deleteUser(userId); + }; + + onUserDisable = (userId: number) => { + this.props.disableUser(userId); + }; + + onUserEnable = (userId: number) => { + this.props.enableUser(userId); + }; + + onGrafanaAdminChange = (isGrafanaAdmin: boolean) => { + const { userId, updateUserPermissions } = this.props; + updateUserPermissions(userId, isGrafanaAdmin); + }; + + onOrgRemove = (orgId: number) => { + const { userId, deleteOrgUser } = this.props; + deleteOrgUser(userId, orgId); + }; + + onOrgRoleChange = (orgId: number, newRole: string) => { + const { userId, updateOrgUserRole } = this.props; + updateOrgUserRole(userId, orgId, newRole); + }; + + onOrgAdd = (orgId: number, role: string) => { + const { user, addOrgUser } = this.props; + addOrgUser(user, orgId, role); + }; + + onSessionRevoke = (tokenId: number) => { + const { userId, revokeSession } = this.props; + revokeSession(tokenId, userId); + }; + + onAllSessionsRevoke = () => { + const { userId, revokeAllSessions } = this.props; + revokeAllSessions(userId); + }; + + onUserSync = () => { + const { userId, syncLdapUser } = this.props; + syncLdapUser(userId); + }; + + render() { + const { navModel, user, orgs, sessions, ldapSyncInfo, isLoading } = this.props; + // const { isLoading } = this.state; + const isLDAPUser = user && user.isExternal && user.authLabels && user.authLabels.includes('LDAP'); + + return ( + + + {user && ( + <> + + {isLDAPUser && config.buildInfo.isEnterprise && ldapSyncInfo && ( + + )} + + + )} + + {orgs && ( + + )} + + {sessions && ( + + )} + + + ); + } +} + +const mapStateToProps = (state: StoreState) => ({ + userId: getRouteParamsId(state.location), + navModel: getNavModel(state.navIndex, 'global-users'), + user: state.userAdmin.user, + sessions: state.userAdmin.sessions, + orgs: state.userAdmin.orgs, + ldapSyncInfo: state.ldap.syncInfo, + isLoading: state.userAdmin.isLoading, + error: state.userAdmin.error, +}); + +const mapDispatchToProps = { + loadAdminUserPage, + updateUser, + setUserPassword, + disableUser, + enableUser, + deleteUser, + updateUserPermissions, + addOrgUser, + updateOrgUserRole, + deleteOrgUser, + revokeSession, + revokeAllSessions, + syncLdapUser, +}; + +export default hot(module)(connect(mapStateToProps, mapDispatchToProps)(UserAdminPage)); diff --git a/public/app/features/admin/UserInfo.tsx b/public/app/features/admin/UserInfo.tsx deleted file mode 100644 index a71a7527978..00000000000 --- a/public/app/features/admin/UserInfo.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import React, { FC } from 'react'; -import { User } from 'app/types'; - -interface Props { - user: User; -} - -export const UserInfo: FC = ({ user }) => { - return ( -
-
- - - - - - - - - - - - - - - - - - - - -
User information
Name{user.name}
Username{user.login}
Email{user.email}
-
-
- ); -}; diff --git a/public/app/features/admin/UserLdapSyncInfo.tsx b/public/app/features/admin/UserLdapSyncInfo.tsx new file mode 100644 index 00000000000..f3410de5ef5 --- /dev/null +++ b/public/app/features/admin/UserLdapSyncInfo.tsx @@ -0,0 +1,82 @@ +import React, { PureComponent } from 'react'; +import { dateTime } from '@grafana/data'; +import { SyncInfo, UserDTO } from 'app/types'; +import { Button, LinkButton } from '@grafana/ui'; + +interface Props { + ldapSyncInfo: SyncInfo; + user: UserDTO; + onUserSync: () => void; +} + +interface State {} + +const syncTimeFormat = 'dddd YYYY-MM-DD HH:mm zz'; +const debugLDAPMappingBaseURL = '/admin/ldap'; + +export class UserLdapSyncInfo extends PureComponent { + onUserSync = () => { + this.props.onUserSync(); + }; + + render() { + const { ldapSyncInfo, user } = this.props; + const nextSyncTime = dateTime(ldapSyncInfo.nextSync).format(syncTimeFormat); + const prevSyncSuccessful = ldapSyncInfo && ldapSyncInfo.prevSync; + const prevSyncTime = prevSyncSuccessful ? dateTime(ldapSyncInfo.prevSync.started).format(syncTimeFormat) : ''; + const debugLDAPMappingURL = `${debugLDAPMappingBaseURL}?user=${user && user.login}`; + + return ( + <> +

LDAP Synchronisation

+
+
+ + + + + + + + + {ldapSyncInfo.enabled ? ( + <> + + + + ) : ( + <> + + + + )} + + + {prevSyncSuccessful ? ( + <> + + + + + ) : ( + + )} + + +
External syncUser synced via LDAP – some changes must be done in LDAP or mappings. + LDAP +
Next scheduled synchronisation{nextSyncTime}Next scheduled synchronisationNot enabled
Last synchronisation{prevSyncTime}SuccessfulLast synchronisation
+
+
+ + + Debug LDAP Mapping + +
+
+ + ); + } +} diff --git a/public/app/features/admin/UserOrgs.tsx b/public/app/features/admin/UserOrgs.tsx new file mode 100644 index 00000000000..6cd048bfad1 --- /dev/null +++ b/public/app/features/admin/UserOrgs.tsx @@ -0,0 +1,271 @@ +import React, { PureComponent } from 'react'; +import { css, cx } from 'emotion'; +import { Modal, Themeable, stylesFactory, withTheme, ConfirmButton, Forms } from '@grafana/ui'; +import { GrafanaTheme } from '@grafana/data'; +import { UserOrg, Organization } from 'app/types'; +import { OrgPicker, OrgSelectItem } from 'app/core/components/Select/OrgPicker'; + +interface Props { + orgs: UserOrg[]; + + onOrgRemove: (orgId: number) => void; + onOrgRoleChange: (orgId: number, newRole: string) => void; + onOrgAdd: (orgId: number, role: string) => void; +} + +interface State { + showAddOrgModal: boolean; +} + +export class UserOrgs extends PureComponent { + state = { + showAddOrgModal: false, + }; + + showOrgAddModal = (show: boolean) => () => { + this.setState({ showAddOrgModal: show }); + }; + + render() { + const { orgs, onOrgRoleChange, onOrgRemove, onOrgAdd } = this.props; + const { showAddOrgModal } = this.state; + const addToOrgContainerClass = css` + margin-top: 0.8rem; + `; + + return ( + <> +

Organisations

+
+
+ + + {orgs.map((org, index) => ( + + ))} + +
+
+
+ + Add user to organization + +
+ +
+ + ); + } +} + +const ORG_ROLES = ['Viewer', 'Editor', 'Admin']; + +const getOrgRowStyles = stylesFactory((theme: GrafanaTheme) => { + return { + removeButton: css` + margin-right: 0.6rem; + text-decoration: underline; + color: ${theme.colors.blue95}; + `, + label: css` + font-weight: 500; + `, + }; +}); + +interface OrgRowProps extends Themeable { + org: UserOrg; + onOrgRemove: (orgId: number) => void; + onOrgRoleChange: (orgId: number, newRole: string) => void; +} + +interface OrgRowState { + currentRole: string; + isChangingRole: boolean; + isRemovingFromOrg: boolean; +} + +class UnThemedOrgRow extends PureComponent { + state = { + currentRole: this.props.org.role, + isChangingRole: false, + isRemovingFromOrg: false, + }; + + onOrgRemove = () => { + const { org } = this.props; + this.props.onOrgRemove(org.orgId); + }; + + onChangeRoleClick = () => { + const { org } = this.props; + this.setState({ isChangingRole: true, currentRole: org.role }); + }; + + onOrgRemoveClick = () => { + this.setState({ isRemovingFromOrg: true }); + }; + + onOrgRoleChange = (event: React.ChangeEvent) => { + const newRole = event.target.value; + this.setState({ currentRole: newRole }); + }; + + onOrgRoleSave = () => { + this.props.onOrgRoleChange(this.props.org.orgId, this.state.currentRole); + }; + + onCancelClick = () => { + this.setState({ isChangingRole: false, isRemovingFromOrg: false }); + }; + + render() { + const { org, theme } = this.props; + const { currentRole, isChangingRole, isRemovingFromOrg } = this.state; + const styles = getOrgRowStyles(theme); + const labelClass = cx('width-16', styles.label); + + return ( + + {org.name} + {isChangingRole ? ( + +
+ +
+ + ) : ( + {org.role} + )} + {!isRemovingFromOrg && ( + +
+ + Change role + +
+ + )} + {!isChangingRole && ( + +
+ + Remove from organisation + +
+ + )} + + ); + } +} + +const OrgRow = withTheme(UnThemedOrgRow); + +const getAddToOrgModalStyles = stylesFactory(() => ({ + modal: css` + width: 500px; + `, + buttonRow: css` + text-align: center; + `, +})); + +interface AddToOrgModalProps { + isOpen: boolean; + onOrgAdd(orgId: number, role: string): void; + onDismiss?(): void; +} + +interface AddToOrgModalState { + selectedOrg: Organization; + role: string; +} + +export class AddToOrgModal extends PureComponent { + state: AddToOrgModalState = { + selectedOrg: null, + role: 'Admin', + }; + + onOrgSelect = (org: OrgSelectItem) => { + this.setState({ selectedOrg: { ...org } }); + }; + + onOrgRoleChange = (event: React.ChangeEvent) => { + this.setState({ + role: event.target.value, + }); + }; + + onAddUserToOrg = () => { + const { selectedOrg, role } = this.state; + this.props.onOrgAdd(selectedOrg.id, role); + }; + + onCancel = () => { + this.props.onDismiss(); + }; + + render() { + const { isOpen } = this.props; + const { role } = this.state; + const styles = getAddToOrgModalStyles(); + const buttonRowClass = cx('gf-form-button-row', styles.buttonRow); + + return ( + +
+
Organisation
+ +
+
+
Role
+
+ +
+
+
+ + Add to organization + + + Cancel + +
+
+ ); + } +} diff --git a/public/app/features/admin/UserPermissions.tsx b/public/app/features/admin/UserPermissions.tsx new file mode 100644 index 00000000000..db9f250dcfe --- /dev/null +++ b/public/app/features/admin/UserPermissions.tsx @@ -0,0 +1,107 @@ +import React, { PureComponent } from 'react'; +import { ConfirmButton } from '@grafana/ui'; +import { cx } from 'emotion'; + +interface Props { + isGrafanaAdmin: boolean; + + onGrafanaAdminChange: (isGrafanaAdmin: boolean) => void; +} + +interface State { + isEditing: boolean; + currentAdminOption: string; +} + +export class UserPermissions extends PureComponent { + state = { + isEditing: false, + currentAdminOption: this.props.isGrafanaAdmin ? 'YES' : 'NO', + }; + + onChangeClick = () => { + this.setState({ isEditing: true }); + }; + + onCancelClick = () => { + this.setState({ + isEditing: false, + currentAdminOption: this.props.isGrafanaAdmin ? 'YES' : 'NO', + }); + }; + + onGrafanaAdminChange = () => { + const { currentAdminOption } = this.state; + const newIsGrafanaAdmin = currentAdminOption === 'YES' ? true : false; + this.props.onGrafanaAdminChange(newIsGrafanaAdmin); + }; + + onAdminOptionSelect = (event: React.ChangeEvent) => { + this.setState({ currentAdminOption: event.target.value }); + }; + + render() { + const { isGrafanaAdmin } = this.props; + const { isEditing, currentAdminOption } = this.state; + const changeButtonContainerClass = cx('pull-right'); + + return ( + <> +

Permissions

+
+
+ + + + + {isEditing ? ( + + ) : ( + + )} + + + +
Grafana Admin +
+ +
+
+ {isGrafanaAdmin ? ( + <> + Yes + + ) : ( + <>No + )} + +
+ + Change + +
+
+
+
+ + ); + } +} diff --git a/public/app/features/admin/UserProfile.tsx b/public/app/features/admin/UserProfile.tsx new file mode 100644 index 00000000000..78ba46cd355 --- /dev/null +++ b/public/app/features/admin/UserProfile.tsx @@ -0,0 +1,329 @@ +import React, { PureComponent, FC } from 'react'; +import { UserDTO } from 'app/types'; +import { cx, css } from 'emotion'; +import { config } from 'app/core/config'; +import { GrafanaTheme } from '@grafana/data'; +import { ConfirmButton, Input, ConfirmModal, InputStatus, Forms, stylesFactory } from '@grafana/ui'; + +interface Props { + user: UserDTO; + + onUserUpdate: (user: UserDTO) => void; + onUserDelete: (userId: number) => void; + onUserDisable: (userId: number) => void; + onUserEnable: (userId: number) => void; + onPasswordChange(password: string): void; +} + +interface State { + isLoading: boolean; + showDeleteModal: boolean; + showDisableModal: boolean; +} + +export class UserProfile extends PureComponent { + state = { + isLoading: false, + showDeleteModal: false, + showDisableModal: false, + }; + + showDeleteUserModal = (show: boolean) => () => { + this.setState({ showDeleteModal: show }); + }; + + showDisableUserModal = (show: boolean) => () => { + this.setState({ showDisableModal: show }); + }; + + onUserDelete = () => { + const { user, onUserDelete } = this.props; + onUserDelete(user.id); + }; + + onUserDisable = () => { + const { user, onUserDisable } = this.props; + onUserDisable(user.id); + }; + + onUserEnable = () => { + const { user, onUserEnable } = this.props; + onUserEnable(user.id); + }; + + onUserNameChange = (newValue: string) => { + const { user, onUserUpdate } = this.props; + onUserUpdate({ + ...user, + name: newValue, + }); + }; + + onUserEmailChange = (newValue: string) => { + const { user, onUserUpdate } = this.props; + onUserUpdate({ + ...user, + email: newValue, + }); + }; + + onUserLoginChange = (newValue: string) => { + const { user, onUserUpdate } = this.props; + onUserUpdate({ + ...user, + login: newValue, + }); + }; + + onPasswordChange = (newValue: string) => { + this.props.onPasswordChange(newValue); + }; + + render() { + const { user } = this.props; + const { showDeleteModal, showDisableModal } = this.state; + const lockMessage = 'Synced via LDAP'; + const styles = getStyles(config.theme); + + return ( + <> +

User information

+
+
+ + + + + + + +
+
+
+ + Delete User + + + {user.isDisabled ? ( + + Enable User + + ) : ( + + Disable User + + )} + +
+
+ + ); + } +} + +const getStyles = stylesFactory((theme: GrafanaTheme) => { + return { + buttonRow: css` + margin-top: 0.8rem; + > * { + margin-right: 16px; + } + `, + }; +}); + +interface UserProfileRowProps { + label: string; + value?: string; + locked?: boolean; + lockMessage?: string; + inputType?: string; + onChange?: (value: string) => void; +} + +interface UserProfileRowState { + value: string; + editing: boolean; +} + +export class UserProfileRow extends PureComponent { + inputElem: HTMLInputElement; + + static defaultProps: Partial = { + value: '', + locked: false, + lockMessage: '', + inputType: 'text', + }; + + state = { + editing: false, + value: this.props.value || '', + }; + + setInputElem = (elem: any) => { + this.inputElem = elem; + }; + + onEditClick = () => { + if (this.props.inputType === 'password') { + // Reset value for password field + this.setState({ editing: true, value: '' }, this.focusInput); + } else { + this.setState({ editing: true }, this.focusInput); + } + }; + + onCancelClick = () => { + this.setState({ editing: false, value: this.props.value || '' }); + }; + + onInputChange = (event: React.ChangeEvent, status?: InputStatus) => { + if (status === InputStatus.Invalid) { + return; + } + + this.setState({ value: event.target.value }); + }; + + onInputBlur = (event: React.FocusEvent, status?: InputStatus) => { + if (status === InputStatus.Invalid) { + return; + } + + this.setState({ value: event.target.value }); + }; + + focusInput = () => { + if (this.inputElem && this.inputElem.focus) { + this.inputElem.focus(); + } + }; + + onSave = () => { + if (this.props.onChange) { + this.props.onChange(this.state.value); + } + }; + + render() { + const { label, locked, lockMessage, inputType } = this.props; + const { value } = this.state; + const labelClass = cx( + 'width-16', + css` + font-weight: 500; + ` + ); + const editButtonContainerClass = cx('pull-right'); + + if (locked) { + return ; + } + + return ( + + {label} + + {this.state.editing ? ( + + ) : ( + {this.props.value} + )} + + +
+ + Edit + +
+ + + ); + } +} + +interface LockedRowProps { + label: string; + value?: any; + lockMessage?: string; +} + +export const LockedRow: FC = ({ label, value, lockMessage }) => { + const lockMessageClass = cx( + 'pull-right', + css` + font-style: italic; + margin-right: 0.6rem; + ` + ); + const labelClass = cx( + 'width-16', + css` + font-weight: 500; + ` + ); + + return ( + + {label} + + {value} + + + {lockMessage} + + + ); +}; diff --git a/public/app/features/admin/UserSessions.tsx b/public/app/features/admin/UserSessions.tsx index c07c90227ca..44e3a8a243f 100644 --- a/public/app/features/admin/UserSessions.tsx +++ b/public/app/features/admin/UserSessions.tsx @@ -1,4 +1,6 @@ import React, { PureComponent } from 'react'; +import { css } from 'emotion'; +import { ConfirmButton, ConfirmModal, Forms } from '@grafana/ui'; import { UserSession } from 'app/types'; interface Props { @@ -8,19 +10,37 @@ interface Props { onAllSessionsRevoke: () => void; } -export class UserSessions extends PureComponent { - handleSessionRevoke = (id: number) => { +interface State { + showLogoutModal: boolean; +} + +export class UserSessions extends PureComponent { + state: State = { + showLogoutModal: false, + }; + + showLogoutConfirmationModal = (show: boolean) => () => { + this.setState({ showLogoutModal: show }); + }; + + onSessionRevoke = (id: number) => { return () => { this.props.onSessionRevoke(id); }; }; - handleAllSessionsRevoke = () => { + onAllSessionsRevoke = () => { + this.setState({ showLogoutModal: false }); this.props.onAllSessionsRevoke(); }; render() { const { sessions } = this.props; + const { showLogoutModal } = this.state; + + const logoutFromAllDevicesClass = css` + margin-top: 0.8rem; + `; return ( <> @@ -45,21 +65,35 @@ export class UserSessions extends PureComponent { {session.clientIp} {`${session.browser} on ${session.os} ${session.osVersion}`} - +
+ + Force logout + +
))} -
+
{sessions.length > 0 && ( - + + Force logout from all devices + )} +
diff --git a/public/app/features/admin/UserSyncInfo.tsx b/public/app/features/admin/UserSyncInfo.tsx index 33be5683751..e2fccc7a6a7 100644 --- a/public/app/features/admin/UserSyncInfo.tsx +++ b/public/app/features/admin/UserSyncInfo.tsx @@ -19,7 +19,7 @@ export class UserSyncInfo extends PureComponent { isSyncing: false, }; - handleSyncClick = async () => { + onSyncClick = async () => { const { onSync } = this.props; this.setState({ isSyncing: true }); try { @@ -41,7 +41,7 @@ export class UserSyncInfo extends PureComponent { return ( <> - diff --git a/public/app/features/admin/ldap/LdapPage.tsx b/public/app/features/admin/ldap/LdapPage.tsx index 5c7b6792539..0db89100768 100644 --- a/public/app/features/admin/ldap/LdapPage.tsx +++ b/public/app/features/admin/ldap/LdapPage.tsx @@ -25,6 +25,7 @@ interface Props { ldapSyncInfo: SyncInfo; ldapError: LdapError; userError?: LdapError; + username?: string; loadLdapState: typeof loadLdapState; loadLdapSyncStatus: typeof loadLdapSyncStatus; @@ -43,8 +44,12 @@ export class LdapPage extends PureComponent { }; async componentDidMount() { - await this.props.clearUserMappingInfo(); + const { username, clearUserMappingInfo, loadUserMapping } = this.props; + await clearUserMappingInfo(); await this.fetchLDAPStatus(); + if (username) { + await loadUserMapping(username); + } this.setState({ isLoading: false }); } @@ -71,7 +76,7 @@ export class LdapPage extends PureComponent { }; render() { - const { ldapUser, userError, ldapError, ldapSyncInfo, ldapConnectionInfo, navModel } = this.props; + const { ldapUser, userError, ldapError, ldapSyncInfo, ldapConnectionInfo, navModel, username } = this.props; const { isLoading } = this.state; return ( @@ -91,7 +96,15 @@ export class LdapPage extends PureComponent {

Test user mapping

- + @@ -117,6 +130,7 @@ export class LdapPage extends PureComponent { const mapStateToProps = (state: StoreState) => ({ navModel: getNavModel(state.navIndex, 'ldap'), + username: state.location.routeParams.user, ldapConnectionInfo: state.ldap.connectionInfo, ldapUser: state.ldap.user, ldapSyncInfo: state.ldap.syncInfo, diff --git a/public/app/features/admin/ldap/LdapUserPage.tsx b/public/app/features/admin/ldap/LdapUserPage.tsx deleted file mode 100644 index 8b88ba7d13d..00000000000 --- a/public/app/features/admin/ldap/LdapUserPage.tsx +++ /dev/null @@ -1,161 +0,0 @@ -import React, { PureComponent } from 'react'; -import { hot } from 'react-hot-loader'; -import { connect } from 'react-redux'; -import { NavModel } from '@grafana/data'; -import { Alert } from '@grafana/ui'; -import Page from 'app/core/components/Page/Page'; -import { getNavModel } from 'app/core/selectors/navModel'; -import { - AppNotificationSeverity, - LdapError, - LdapUser, - StoreState, - User, - UserSession, - SyncInfo, - LdapUserSyncInfo, -} from 'app/types'; -import { - clearUserError, - loadLdapUserInfo, - revokeSession, - revokeAllSessions, - loadLdapSyncStatus, - syncUser, -} from '../state/actions'; -import { LdapUserInfo } from './LdapUserInfo'; -import { getRouteParamsId } from 'app/core/selectors/location'; -import { UserSessions } from '../UserSessions'; -import { UserInfo } from '../UserInfo'; -import { UserSyncInfo } from '../UserSyncInfo'; - -interface Props { - navModel: NavModel; - userId: number; - user: User; - sessions: UserSession[]; - ldapUser: LdapUser; - userError?: LdapError; - ldapSyncInfo?: SyncInfo; - - loadLdapUserInfo: typeof loadLdapUserInfo; - clearUserError: typeof clearUserError; - loadLdapSyncStatus: typeof loadLdapSyncStatus; - syncUser: typeof syncUser; - revokeSession: typeof revokeSession; - revokeAllSessions: typeof revokeAllSessions; -} - -interface State { - isLoading: boolean; -} - -export class LdapUserPage extends PureComponent { - state = { - isLoading: true, - }; - - async componentDidMount() { - const { userId, loadLdapUserInfo, loadLdapSyncStatus } = this.props; - try { - await loadLdapUserInfo(userId); - await loadLdapSyncStatus(); - } finally { - this.setState({ isLoading: false }); - } - } - - onClearUserError = () => { - this.props.clearUserError(); - }; - - onSyncUser = () => { - const { syncUser, user } = this.props; - if (syncUser && user) { - syncUser(user.id); - } - }; - - onSessionRevoke = (tokenId: number) => { - const { userId, revokeSession } = this.props; - revokeSession(tokenId, userId); - }; - - onAllSessionsRevoke = () => { - const { userId, revokeAllSessions } = this.props; - revokeAllSessions(userId); - }; - - isUserError = (): boolean => { - return !!(this.props.userError && this.props.userError.title); - }; - - render() { - const { user, ldapUser, userError, navModel, sessions, ldapSyncInfo } = this.props; - const { isLoading } = this.state; - - const userSyncInfo: LdapUserSyncInfo = {}; - if (ldapSyncInfo) { - userSyncInfo.nextSync = ldapSyncInfo.nextSync; - } - if (user) { - userSyncInfo.prevSync = (user as any).updatedAt; - } - - return ( - - -
- This user is synced via LDAP – All changes must be done in LDAP or mappings. -
- {userError && userError.title && ( -
- -
- )} - - {userSyncInfo && ( - - )} - - {ldapUser && } - {!ldapUser && user && } - - {sessions && ( - - )} -
-
- ); - } -} - -const mapStateToProps = (state: StoreState) => ({ - userId: getRouteParamsId(state.location), - navModel: getNavModel(state.navIndex, 'global-users'), - user: state.ldapUser.user, - ldapUser: state.ldapUser.ldapUser, - userError: state.ldapUser.userError, - ldapSyncInfo: state.ldapUser.ldapSyncInfo, - sessions: state.ldapUser.sessions, -}); - -const mapDispatchToProps = { - loadLdapUserInfo, - loadLdapSyncStatus, - syncUser, - revokeSession, - revokeAllSessions, - clearUserError, -}; - -export default hot(module)(connect(mapStateToProps, mapDispatchToProps)(LdapUserPage)); diff --git a/public/app/features/admin/state/actions.ts b/public/app/features/admin/state/actions.ts index ae5f5f47e22..c4437e33b4b 100644 --- a/public/app/features/admin/state/actions.ts +++ b/public/app/features/admin/state/actions.ts @@ -1,34 +1,196 @@ +import { updateLocation } from 'app/core/actions'; import config from 'app/core/config'; -import { ThunkResult } from 'app/types'; +import { dateTime } from '@grafana/data'; +import { getBackendSrv } from '@grafana/runtime'; +import { ThunkResult, LdapUser, UserSession, UserDTO } from 'app/types'; + import { - getLdapState, - getLdapSyncStatus, - getUser, - getUserInfo, - getUserSessions, - revokeAllUserSessions, - revokeUserSession, - syncLdapUser, -} from './apis'; -import { - clearUserErrorAction, - clearUserMappingInfoAction, - ldapConnectionInfoLoadedAction, - ldapFailedAction, - ldapSyncStatusLoadedAction, - userLoadedAction, - userMappingInfoFailedAction, - userMappingInfoLoadedAction, + userAdminPageLoadedAction, + userProfileLoadedAction, + userOrgsLoadedAction, userSessionsLoadedAction, - userSyncFailedAction, + userAdminPageFailedAction, + ldapConnectionInfoLoadedAction, + ldapSyncStatusLoadedAction, + userMappingInfoLoadedAction, + userMappingInfoFailedAction, + clearUserMappingInfoAction, + clearUserErrorAction, + ldapFailedAction, } from './reducers'; -// Actions +// UserAdminPage + +export function loadAdminUserPage(userId: number): ThunkResult { + return async dispatch => { + try { + dispatch(userAdminPageLoadedAction(false)); + await dispatch(loadUserProfile(userId)); + await dispatch(loadUserOrgs(userId)); + await dispatch(loadUserSessions(userId)); + if (config.ldapEnabled && config.buildInfo.isEnterprise) { + await dispatch(loadLdapSyncStatus()); + } + dispatch(userAdminPageLoadedAction(true)); + } catch (error) { + console.log(error); + error.isHandled = true; + const userError = { + title: error.data.message, + body: error.data.error, + }; + dispatch(userAdminPageFailedAction(userError)); + } + }; +} + +export function loadUserProfile(userId: number): ThunkResult { + return async dispatch => { + const user = await getBackendSrv().get(`/api/users/${userId}`); + dispatch(userProfileLoadedAction(user)); + }; +} + +export function updateUser(user: UserDTO): ThunkResult { + return async dispatch => { + await getBackendSrv().put(`/api/users/${user.id}`, user); + dispatch(loadAdminUserPage(user.id)); + }; +} + +export function setUserPassword(userId: number, password: string): ThunkResult { + return async dispatch => { + const payload = { password }; + await getBackendSrv().put(`/api/admin/users/${userId}/password`, payload); + dispatch(loadAdminUserPage(userId)); + }; +} + +export function disableUser(userId: number): ThunkResult { + return async dispatch => { + await getBackendSrv().post(`/api/admin/users/${userId}/disable`); + // dispatch(loadAdminUserPage(userId)); + dispatch(updateLocation({ path: '/admin/users' })); + }; +} + +export function enableUser(userId: number): ThunkResult { + return async dispatch => { + await getBackendSrv().post(`/api/admin/users/${userId}/enable`); + dispatch(loadAdminUserPage(userId)); + }; +} + +export function deleteUser(userId: number): ThunkResult { + return async dispatch => { + await getBackendSrv().delete(`/api/admin/users/${userId}`); + dispatch(updateLocation({ path: '/admin/users' })); + }; +} + +export function updateUserPermissions(userId: number, isGrafanaAdmin: boolean): ThunkResult { + return async dispatch => { + const payload = { isGrafanaAdmin }; + await getBackendSrv().put(`/api/admin/users/${userId}/permissions`, payload); + dispatch(loadAdminUserPage(userId)); + }; +} + +export function loadUserOrgs(userId: number): ThunkResult { + return async dispatch => { + const orgs = await getBackendSrv().get(`/api/users/${userId}/orgs`); + dispatch(userOrgsLoadedAction(orgs)); + }; +} + +export function addOrgUser(user: UserDTO, orgId: number, role: string): ThunkResult { + return async dispatch => { + const payload = { + loginOrEmail: user.login, + role: role, + }; + await getBackendSrv().post(`/api/orgs/${orgId}/users/`, payload); + dispatch(loadAdminUserPage(user.id)); + }; +} + +export function updateOrgUserRole(userId: number, orgId: number, role: string): ThunkResult { + return async dispatch => { + const payload = { role }; + await getBackendSrv().patch(`/api/orgs/${orgId}/users/${userId}`, payload); + dispatch(loadAdminUserPage(userId)); + }; +} + +export function deleteOrgUser(userId: number, orgId: number): ThunkResult { + return async dispatch => { + await getBackendSrv().delete(`/api/orgs/${orgId}/users/${userId}`); + dispatch(loadAdminUserPage(userId)); + }; +} + +export function loadUserSessions(userId: number): ThunkResult { + return async dispatch => { + const tokens = await getBackendSrv().get(`/api/admin/users/${userId}/auth-tokens`); + tokens.reverse(); + const sessions = tokens.map((session: UserSession) => { + return { + id: session.id, + isActive: session.isActive, + seenAt: dateTime(session.seenAt).fromNow(), + createdAt: dateTime(session.createdAt).format('MMMM DD, YYYY'), + clientIp: session.clientIp, + browser: session.browser, + browserVersion: session.browserVersion, + os: session.os, + osVersion: session.osVersion, + device: session.device, + }; + }); + dispatch(userSessionsLoadedAction(sessions)); + }; +} + +export function revokeSession(tokenId: number, userId: number): ThunkResult { + return async dispatch => { + const payload = { authTokenId: tokenId }; + await getBackendSrv().post(`/api/admin/users/${userId}/revoke-auth-token`, payload); + dispatch(loadUserSessions(userId)); + }; +} + +export function revokeAllSessions(userId: number): ThunkResult { + return async dispatch => { + await getBackendSrv().post(`/api/admin/users/${userId}/logout`); + dispatch(loadUserSessions(userId)); + }; +} + +// LDAP user actions + +export function loadLdapSyncStatus(): ThunkResult { + return async dispatch => { + // Available only in enterprise + if (config.buildInfo.isEnterprise) { + const syncStatus = await getBackendSrv().get(`/api/admin/ldap-sync-status`); + dispatch(ldapSyncStatusLoadedAction(syncStatus)); + } + }; +} + +export function syncLdapUser(userId: number): ThunkResult { + return async dispatch => { + await getBackendSrv().post(`/api/admin/ldap/sync/${userId}`); + dispatch(loadAdminUserPage(userId)); + }; +} + +// LDAP debug page export function loadLdapState(): ThunkResult { return async dispatch => { try { - const connectionInfo = await getLdapState(); + const connectionInfo = await getBackendSrv().get(`/api/admin/ldap/status`); dispatch(ldapConnectionInfoLoadedAction(connectionInfo)); } catch (error) { error.isHandled = true; @@ -41,20 +203,17 @@ export function loadLdapState(): ThunkResult { }; } -export function loadLdapSyncStatus(): ThunkResult { - return async dispatch => { - if (config.buildInfo.isEnterprise) { - // Available only in enterprise - const syncStatus = await getLdapSyncStatus(); - dispatch(ldapSyncStatusLoadedAction(syncStatus)); - } - }; -} - export function loadUserMapping(username: string): ThunkResult { return async dispatch => { try { - const userInfo = await getUserInfo(username); + const response = await getBackendSrv().get(`/api/admin/ldap/${username}`); + const { name, surname, email, login, isGrafanaAdmin, isDisabled, roles, teams } = response; + const userInfo: LdapUser = { + info: { name, surname, email, login }, + permissions: { isGrafanaAdmin, isDisabled }, + roles, + teams, + }; dispatch(userMappingInfoLoadedAction(userInfo)); } catch (error) { error.isHandled = true; @@ -80,54 +239,3 @@ export function clearUserMappingInfo(): ThunkResult { dispatch(clearUserMappingInfoAction()); }; } - -export function syncUser(userId: number): ThunkResult { - return async dispatch => { - try { - await syncLdapUser(userId); - dispatch(loadLdapUserInfo(userId)); - dispatch(loadLdapSyncStatus()); - } catch (error) { - dispatch(userSyncFailedAction()); - } - }; -} - -export function loadLdapUserInfo(userId: number): ThunkResult { - return async dispatch => { - try { - const user = await getUser(userId); - dispatch(userLoadedAction(user)); - dispatch(loadUserSessions(userId)); - dispatch(loadUserMapping(user.login)); - } catch (error) { - error.isHandled = true; - const userError = { - title: error.data.message, - body: error.data.error, - }; - dispatch(userMappingInfoFailedAction(userError)); - } - }; -} - -export function loadUserSessions(userId: number): ThunkResult { - return async dispatch => { - const sessions = await getUserSessions(userId); - dispatch(userSessionsLoadedAction(sessions)); - }; -} - -export function revokeSession(tokenId: number, userId: number): ThunkResult { - return async dispatch => { - await revokeUserSession(tokenId, userId); - dispatch(loadUserSessions(userId)); - }; -} - -export function revokeAllSessions(userId: number): ThunkResult { - return async dispatch => { - await revokeAllUserSessions(userId); - dispatch(loadUserSessions(userId)); - }; -} diff --git a/public/app/features/admin/state/apis.ts b/public/app/features/admin/state/apis.ts index ea416ad4d35..1166fa4dc01 100644 --- a/public/app/features/admin/state/apis.ts +++ b/public/app/features/admin/state/apis.ts @@ -1,6 +1,4 @@ import { getBackendSrv } from '@grafana/runtime'; -import { dateTime } from '@grafana/data'; -import { LdapUser, LdapConnectionInfo, UserSession, SyncInfo, User } from 'app/types'; export interface ServerStat { name: string; @@ -33,60 +31,3 @@ export const getServerStats = async (): Promise => { throw error; } }; - -export const getLdapState = async (): Promise => { - return await getBackendSrv().get(`/api/admin/ldap/status`); -}; - -export const getLdapSyncStatus = async (): Promise => { - return await getBackendSrv().get(`/api/admin/ldap-sync-status`); -}; - -export const syncLdapUser = async (userId: number) => { - return await getBackendSrv().post(`/api/admin/ldap/sync/${userId}`); -}; - -export const getUserInfo = async (username: string): Promise => { - const response = await getBackendSrv().get(`/api/admin/ldap/${username}`); - const { name, surname, email, login, isGrafanaAdmin, isDisabled, roles, teams } = response; - return { - info: { name, surname, email, login }, - permissions: { isGrafanaAdmin, isDisabled }, - roles, - teams, - }; -}; - -export const getUser = async (id: number): Promise => { - return await getBackendSrv().get('/api/users/' + id); -}; - -export const getUserSessions = async (id: number) => { - const sessions = await getBackendSrv().get('/api/admin/users/' + id + '/auth-tokens'); - sessions.reverse(); - - return sessions.map((session: UserSession) => { - return { - id: session.id, - isActive: session.isActive, - seenAt: dateTime(session.seenAt).fromNow(), - createdAt: dateTime(session.createdAt).format('MMMM DD, YYYY'), - clientIp: session.clientIp, - browser: session.browser, - browserVersion: session.browserVersion, - os: session.os, - osVersion: session.osVersion, - device: session.device, - }; - }); -}; - -export const revokeUserSession = async (tokenId: number, userId: number) => { - return await getBackendSrv().post(`/api/admin/users/${userId}/revoke-auth-token`, { - authTokenId: tokenId, - }); -}; - -export const revokeAllUserSessions = async (userId: number) => { - return await getBackendSrv().post(`/api/admin/users/${userId}/logout`); -}; diff --git a/public/app/features/admin/state/reducers.test.ts b/public/app/features/admin/state/reducers.test.ts index 126987f3c57..e3bff295006 100644 --- a/public/app/features/admin/state/reducers.test.ts +++ b/public/app/features/admin/state/reducers.test.ts @@ -1,18 +1,17 @@ import { reducerTester } from 'test/core/redux/reducerTester'; import { - clearUserErrorAction, clearUserMappingInfoAction, ldapConnectionInfoLoadedAction, ldapFailedAction, ldapReducer, ldapSyncStatusLoadedAction, - ldapUserReducer, - userLoadedAction, + userAdminReducer, + userProfileLoadedAction, userMappingInfoFailedAction, userMappingInfoLoadedAction, userSessionsLoadedAction, } from './reducers'; -import { LdapState, LdapUser, LdapUserState, User } from 'app/types'; +import { LdapState, LdapUser, UserAdminState, UserDTO } from 'app/types'; const makeInitialLdapState = (): LdapState => ({ connectionInfo: [], @@ -23,11 +22,11 @@ const makeInitialLdapState = (): LdapState => ({ userError: null, }); -const makeInitialLdapUserState = (): LdapUserState => ({ +const makeInitialUserAdminState = (): UserAdminState => ({ user: null, - ldapUser: null, - ldapSyncInfo: null, sessions: [], + orgs: [], + isLoading: true, }); const getTestUserMapping = (): LdapUser => ({ @@ -45,13 +44,14 @@ const getTestUserMapping = (): LdapUser => ({ teams: [], }); -const getTestUser = (): User => ({ +const getTestUser = (): UserDTO => ({ id: 1, email: 'user@localhost', login: 'user', name: 'User', avatarUrl: '', - label: '', + isGrafanaAdmin: false, + isDisabled: false, }); describe('LDAP page reducer', () => { @@ -203,32 +203,28 @@ describe('LDAP page reducer', () => { }); }); -describe('Edit LDAP user page reducer', () => { +describe('Edit Admin user page reducer', () => { describe('When user loaded', () => { it('should set user and clear user error', () => { const initialState = { - ...makeInitialLdapUserState(), - userError: { - title: 'User not found', - body: 'Cannot find user', - }, + ...makeInitialUserAdminState(), }; - reducerTester() - .givenReducer(ldapUserReducer, initialState) - .whenActionIsDispatched(userLoadedAction(getTestUser())) + reducerTester() + .givenReducer(userAdminReducer, initialState) + .whenActionIsDispatched(userProfileLoadedAction(getTestUser())) .thenStateShouldEqual({ - ...makeInitialLdapUserState(), + ...makeInitialUserAdminState(), + user: getTestUser(), - userError: null, }); }); }); describe('when userSessionsLoadedAction is dispatched', () => { it('then state should be correct', () => { - reducerTester() - .givenReducer(ldapUserReducer, { ...makeInitialLdapUserState() }) + reducerTester() + .givenReducer(userAdminReducer, { ...makeInitialUserAdminState() }) .whenActionIsDispatched( userSessionsLoadedAction([ { @@ -246,7 +242,7 @@ describe('Edit LDAP user page reducer', () => { ]) ) .thenStateShouldEqual({ - ...makeInitialLdapUserState(), + ...makeInitialUserAdminState(), sessions: [ { browser: 'Chrome', @@ -264,80 +260,4 @@ describe('Edit LDAP user page reducer', () => { }); }); }); - - describe('when userMappingInfoLoadedAction is dispatched', () => { - it('then state should be correct', () => { - reducerTester() - .givenReducer(ldapUserReducer, { - ...makeInitialLdapUserState(), - }) - .whenActionIsDispatched(userMappingInfoLoadedAction(getTestUserMapping())) - .thenStateShouldEqual({ - ...makeInitialLdapUserState(), - ldapUser: getTestUserMapping(), - }); - }); - }); - - describe('when userMappingInfoFailedAction is dispatched', () => { - it('then state should be correct', () => { - reducerTester() - .givenReducer(ldapUserReducer, { ...makeInitialLdapUserState() }) - .whenActionIsDispatched( - userMappingInfoFailedAction({ - title: 'User not found', - body: 'Cannot find user', - }) - ) - .thenStateShouldEqual({ - ...makeInitialLdapUserState(), - userError: { - title: 'User not found', - body: 'Cannot find user', - }, - }); - }); - }); - - describe('when clearUserErrorAction is dispatched', () => { - it('then state should be correct', () => { - reducerTester() - .givenReducer(ldapUserReducer, { - ...makeInitialLdapUserState(), - userError: { - title: 'User not found', - body: 'Cannot find user', - }, - }) - .whenActionIsDispatched(clearUserErrorAction()) - .thenStateShouldEqual({ - ...makeInitialLdapUserState(), - userError: null, - }); - }); - }); - - describe('when ldapSyncStatusLoadedAction is dispatched', () => { - it('then state should be correct', () => { - reducerTester() - .givenReducer(ldapUserReducer, { - ...makeInitialLdapUserState(), - }) - .whenActionIsDispatched( - ldapSyncStatusLoadedAction({ - enabled: true, - schedule: '0 0 * * * *', - nextSync: '2019-01-01T12:00:00Z', - }) - ) - .thenStateShouldEqual({ - ...makeInitialLdapUserState(), - ldapSyncInfo: { - enabled: true, - schedule: '0 0 * * * *', - nextSync: '2019-01-01T12:00:00Z', - }, - }); - }); - }); }); diff --git a/public/app/features/admin/state/reducers.ts b/public/app/features/admin/state/reducers.ts index 19cab38d6e8..784bd3dd0ed 100644 --- a/public/app/features/admin/state/reducers.ts +++ b/public/app/features/admin/state/reducers.ts @@ -4,10 +4,12 @@ import { LdapError, LdapState, LdapUser, - LdapUserState, SyncInfo, - User, + UserAdminState, + UserDTO, + UserOrg, UserSession, + UserAdminError, } from 'app/types'; const initialLdapState: LdapState = { @@ -18,13 +20,6 @@ const initialLdapState: LdapState = { userError: null, }; -const initialLdapUserState: LdapUserState = { - user: null, - ldapUser: null, - ldapSyncInfo: null, - sessions: [], -}; - const ldapSlice = createSlice({ name: 'ldap', initialState: initialLdapState, @@ -75,59 +70,54 @@ export const { export const ldapReducer = ldapSlice.reducer; -const ldapUserSlice = createSlice({ - name: 'ldapUser', - initialState: initialLdapUserState, +// UserAdminPage + +const initialUserAdminState: UserAdminState = { + user: null, + sessions: [], + orgs: [], + isLoading: true, +}; + +export const userAdminSlice = createSlice({ + name: 'userAdmin', + initialState: initialUserAdminState, reducers: { - userLoadedAction: (state, action: PayloadAction): LdapUserState => ({ + userProfileLoadedAction: (state, action: PayloadAction): UserAdminState => ({ ...state, user: action.payload, - userError: null, }), - userSessionsLoadedAction: (state, action: PayloadAction): LdapUserState => ({ + userOrgsLoadedAction: (state, action: PayloadAction): UserAdminState => ({ + ...state, + orgs: action.payload, + }), + userSessionsLoadedAction: (state, action: PayloadAction): UserAdminState => ({ ...state, sessions: action.payload, }), - userSyncFailedAction: (state, action: PayloadAction): LdapUserState => state, + userAdminPageLoadedAction: (state, action: PayloadAction): UserAdminState => ({ + ...state, + isLoading: !action.payload, + }), + userAdminPageFailedAction: (state, action: PayloadAction): UserAdminState => ({ + ...state, + error: action.payload, + isLoading: false, + }), }, - extraReducers: builder => - builder - .addCase( - userMappingInfoLoadedAction, - (state, action): LdapUserState => ({ - ...state, - ldapUser: action.payload, - }) - ) - .addCase( - userMappingInfoFailedAction, - (state, action): LdapUserState => ({ - ...state, - ldapUser: null, - userError: action.payload, - }) - ) - .addCase( - clearUserErrorAction, - (state, action): LdapUserState => ({ - ...state, - userError: null, - }) - ) - .addCase( - ldapSyncStatusLoadedAction, - (state, action): LdapUserState => ({ - ...state, - ldapSyncInfo: action.payload, - }) - ), }); -export const { userLoadedAction, userSessionsLoadedAction, userSyncFailedAction } = ldapUserSlice.actions; +export const { + userProfileLoadedAction, + userOrgsLoadedAction, + userSessionsLoadedAction, + userAdminPageLoadedAction, + userAdminPageFailedAction, +} = userAdminSlice.actions; -export const ldapUserReducer = ldapUserSlice.reducer; +export const userAdminReducer = userAdminSlice.reducer; export default { ldap: ldapReducer, - ldapUser: ldapUserReducer, + userAdmin: userAdminReducer, }; diff --git a/public/app/routes/routes.ts b/public/app/routes/routes.ts index 537e7f9d1c7..7966be16bee 100644 --- a/public/app/routes/routes.ts +++ b/public/app/routes/routes.ts @@ -6,6 +6,7 @@ import CreateFolderCtrl from 'app/features/folders/CreateFolderCtrl'; import FolderDashboardsCtrl from 'app/features/folders/FolderDashboardsCtrl'; import DashboardImportCtrl from 'app/features/manage-dashboards/DashboardImportCtrl'; import LdapPage from 'app/features/admin/ldap/LdapPage'; +import UserAdminPage from 'app/features/admin/UserAdminPage'; import config from 'app/core/config'; import { ILocationProvider, route } from 'angular'; // Types @@ -298,9 +299,15 @@ export function setupAngularRoutes($routeProvider: route.IRouteProvider, $locati templateUrl: 'public/app/features/admin/partials/new_user.html', controller: 'AdminEditUserCtrl', }) + // .when('/admin/users/edit/:id', { + // templateUrl: 'public/app/features/admin/partials/edit_user.html', + // controller: 'AdminEditUserCtrl', + // }) .when('/admin/users/edit/:id', { - templateUrl: 'public/app/features/admin/partials/edit_user.html', - controller: 'AdminEditUserCtrl', + template: '', + resolve: { + component: () => UserAdminPage, + }, }) .when('/admin/orgs', { templateUrl: 'public/app/features/admin/partials/orgs.html', diff --git a/public/app/types/ldap.ts b/public/app/types/ldap.ts index cebb32958da..e64af8eb156 100644 --- a/public/app/types/ldap.ts +++ b/public/app/types/ldap.ts @@ -1,5 +1,3 @@ -import { User, UserSession } from 'app/types'; - interface LdapMapping { cfgAttrValue: string; ldapValue: string; @@ -85,11 +83,3 @@ export interface LdapState { userError?: LdapError; ldapError?: LdapError; } - -export interface LdapUserState { - user?: User; - ldapUser?: LdapUser; - ldapSyncInfo?: SyncInfo; - sessions?: UserSession[]; - userError?: LdapError; -} diff --git a/public/app/types/store.ts b/public/app/types/store.ts index 7d8a2dac35c..b02cd1be549 100644 --- a/public/app/types/store.ts +++ b/public/app/types/store.ts @@ -9,12 +9,12 @@ import { FolderState } from './folders'; import { DashboardState } from './dashboard'; import { DataSourcesState } from './datasources'; import { ExploreState } from './explore'; -import { UsersState, UserState } from './user'; +import { UsersState, UserState, UserAdminState } from './user'; import { OrganizationState } from './organization'; import { AppNotificationsState } from './appNotifications'; import { PluginsState } from './plugins'; import { ApplicationState } from './application'; -import { LdapState, LdapUserState } from './ldap'; +import { LdapState } from './ldap'; import { PanelEditorState } from '../features/dashboard/panel_editor/state/reducers'; import { ApiKeysState } from './apiKeys'; @@ -36,8 +36,8 @@ export interface StoreState { plugins: PluginsState; application: ApplicationState; ldap: LdapState; - ldapUser: LdapUserState; apiKeys: ApiKeysState; + userAdmin: UserAdminState; } /* diff --git a/public/app/types/user.ts b/public/app/types/user.ts index 095a7885a26..44202fe1d5d 100644 --- a/public/app/types/user.ts +++ b/public/app/types/user.ts @@ -22,6 +22,21 @@ export interface User { orgId?: number; } +export interface UserDTO { + id: number; + login: string; + email: string; + name: string; + isGrafanaAdmin: boolean; + isDisabled: boolean; + isExternal?: boolean; + updatedAt?: string; + authLabels?: string[]; + theme?: string; + avatarUrl?: string; + orgId?: number; +} + export interface Invitee { code: string; createdOn: string; @@ -67,3 +82,22 @@ export interface UserSession { osVersion: string; device: string; } + +export interface UserOrg { + name: string; + orgId: number; + role: string; +} + +export interface UserAdminState { + user: UserDTO; + sessions: UserSession[]; + orgs: UserOrg[]; + isLoading: boolean; + error?: UserAdminError; +} + +export interface UserAdminError { + title: string; + body: string; +} diff --git a/public/sass/layout/_page.scss b/public/sass/layout/_page.scss index 66b6c47356a..af19956c6c7 100644 --- a/public/sass/layout/_page.scss +++ b/public/sass/layout/_page.scss @@ -94,6 +94,7 @@ .page-body { padding-top: $spacer * 2; + padding-bottom: $spacer * 4; } .page-heading {