diff --git a/packages/components/package-lock.json b/packages/components/package-lock.json index 1f1f183540..dd94d75337 100644 --- a/packages/components/package-lock.json +++ b/packages/components/package-lock.json @@ -1,12 +1,12 @@ { "name": "@labkey/components", - "version": "7.29.4", + "version": "7.29.5-fb-totpReset.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@labkey/components", - "version": "7.29.4", + "version": "7.29.5-fb-totpReset.2", "license": "SEE LICENSE IN LICENSE.txt", "dependencies": { "@hello-pangea/dnd": "18.0.1", diff --git a/packages/components/package.json b/packages/components/package.json index 40d54e6d32..2695e2a8b8 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -1,6 +1,6 @@ { "name": "@labkey/components", - "version": "7.29.4", + "version": "7.29.5-fb-totpReset.2", "description": "Components, models, actions, and utility functions for LabKey applications and pages", "sideEffects": false, "files": [ diff --git a/packages/components/releaseNotes/components.md b/packages/components/releaseNotes/components.md index 5e7639e679..f717b9a48e 100644 --- a/packages/components/releaseNotes/components.md +++ b/packages/components/releaseNotes/components.md @@ -1,6 +1,10 @@ # @labkey/components Components, models, actions, and utility functions for LabKey applications and pages +### version 7.X +*Released*: X April 2026 +- GitHub Issue 848: Add the ability to reset TOTP settings in the apps + ### version 7.29.4 *Released*: 9 April 2026 - GitHub Issue 954: Add error for duplicate values for parent inputs diff --git a/packages/components/src/internal/components/user/UserDetailsPanel.tsx b/packages/components/src/internal/components/user/UserDetailsPanel.tsx index 62db13305f..63c5309672 100644 --- a/packages/components/src/internal/components/user/UserDetailsPanel.tsx +++ b/packages/components/src/internal/components/user/UserDetailsPanel.tsx @@ -2,7 +2,7 @@ * Copyright (c) 2018-2019 LabKey Corporation. All rights reserved. No portion of this work may be reproduced in * any form or by any electronic or mechanical means without written permission from LabKey Corporation. */ -import React, { FC, ReactNode } from 'react'; +import React, { FC, ReactNode, useCallback, useEffect, useState } from 'react'; import { Map } from 'immutable'; import { getServerContext, Utils } from '@labkey/api'; @@ -25,7 +25,9 @@ import { getRolesByUniqueName } from '../permissions/actions'; import { AppLink } from '../../url/AppLink'; +import { hasTotpSettings } from './actions'; import { UserResetPasswordConfirmModal } from './UserResetPasswordConfirmModal'; +import { UserResetTotpSettingsConfirmModal } from './UserResetTotpSettingsConfirmModal'; import { UserDeleteConfirmModal } from './UserDeleteConfirmModal'; import { UserActivateChangeConfirmModal } from './UserActivateChangeConfirmModal'; import { ADMIN_KEY } from '../../app/constants'; @@ -83,130 +85,123 @@ interface Props { userId: number; } -interface State { - loading: boolean; - policy?: SecurityPolicy; - rolesByUniqueName?: Map; - showDialog: string; - userProperties: Record; -} - -export class UserDetailsPanel extends React.PureComponent { - static defaultProps = { - allowDelete: true, - allowResetPassword: true, - api: getDefaultAPIWrapper().security, - showGroupListLinks: true, - showPermissionListLinks: true, - }; - - constructor(props: Props) { - super(props); - - this.state = { - loading: false, - policy: undefined, - rolesByUniqueName: undefined, - showDialog: undefined, - userProperties: undefined, - }; - } - - componentDidMount(): void { - this.loadUserDetails(); - this.loadPolicyAndRoles(); - } - - componentDidUpdate(prevProps: Readonly): void { - if (this.props.userId !== prevProps.userId) { - this.loadUserDetails(); - } - } - - loadPolicyAndRoles = async (): Promise => { - const { policy, rolesByUniqueName, container, currentUser, api } = this.props; - +export const UserDetailsPanel: FC = props => { + const { + allowDelete = true, + allowResetPassword = true, + api = getDefaultAPIWrapper().security, + container, + currentUser, + displayName, + isSelf, + onUsersStateChangeComplete, + policy, + rolesByUniqueName, + rootPolicy, + showGroupListLinks = true, + showPermissionListLinks = true, + toggleDetailsModal, + userId, + } = props; + + const [loading, setLoading] = useState(false); + const [policyState, setPolicyState] = useState(undefined); + const [rolesByUniqueNameState, setRolesByUniqueNameState] = useState | undefined>( + undefined + ); + const [showDialog, setShowDialog] = useState(undefined); + const [showResetTotp, setShowResetTotp] = useState(false); + const [userProperties, setUserProperties] = useState | undefined>(undefined); + + const loadPolicyAndRoles = useCallback(async () => { if (currentUser.isAdmin && !policy && !rolesByUniqueName && container) { try { const policy_ = await api.fetchPolicy(container.id); const roles = await api.fetchRoles(); - this.setState({ policy: policy_, rolesByUniqueName: getRolesByUniqueName(roles) }); + setPolicyState(policy_); + setRolesByUniqueNameState(getRolesByUniqueName(roles)); } catch (e) { console.error(e); } } - }; - - loadUserDetails = async (): Promise => { - const { userId, isSelf, api, displayName } = this.props; + }, [api, container, currentUser.isAdmin, policy, rolesByUniqueName]); + const loadUserDetails = useCallback(async () => { if (!userId) { - this.setState({ userProperties: undefined }); + setUserProperties(undefined); + setShowResetTotp(false); return; } - this.setState({ loading: true }); + setLoading(true); try { if (isSelf) { const response = await api.getUserProperties(userId); - this.setState({ userProperties: response.props }); + setUserProperties(response.props); } else { const response = await api.getUserPropertiesForOther(userId); if (!Utils.isEmptyObj(response)) { - this.setState({ userProperties: response }); + setUserProperties(response); } else { - this.setState({ userProperties: { UserId: userId, DisplayName: displayName } }); + setUserProperties({ UserId: userId, DisplayName: displayName }); } } } catch (e) { - this.setState({ userProperties: undefined }); + setUserProperties(undefined); } - this.setState({ loading: false }); - }; - - toggleDialog = (name: string): void => { - this.setState({ showDialog: name }); - }; - - closeDialog = (): void => { - this.toggleDialog(undefined); - }; - - toggleResetDialog = (): void => { - this.toggleDialog('reset'); - }; - - toggleDeleteDialog = (): void => { - this.toggleDialog('delete'); - }; - - toggleActivateDialog = (): void => { - this.toggleDialog('reactivate'); - }; - - toggleDeactivateDialog = (): void => { - this.toggleDialog('deactivate'); - }; - - onUsersStateChangeComplete = (response: any, isDelete: boolean = false): void => { - this.toggleDialog(undefined); // close dialog - if (!isDelete) { - this.loadUserDetails(); // reload to pickup new user state + if (currentUser.isRootAdmin) { + try { + setShowResetTotp(await hasTotpSettings(userId)); + } catch (e) { + setShowResetTotp(false); + } } - this.props.onUsersStateChangeComplete?.(response, isDelete); - }; + setLoading(false); + }, [api, currentUser.isRootAdmin, displayName, isSelf, userId]); + + useEffect(() => { + loadUserDetails(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [userId]); + + useEffect(() => { + loadPolicyAndRoles(); + }, [loadPolicyAndRoles]); + + const toggleDialog = useCallback((name?: string) => { + setShowDialog(name); + }, []); + + const closeDialog = useCallback(() => toggleDialog(undefined), [toggleDialog]); + const toggleResetDialog = useCallback(() => toggleDialog('reset'), [toggleDialog]); + const toggleResetTotpDialog = useCallback(() => toggleDialog('resetTotp'), [toggleDialog]); + const toggleDeleteDialog = useCallback(() => toggleDialog('delete'), [toggleDialog]); + const toggleActivateDialog = useCallback(() => toggleDialog('reactivate'), [toggleDialog]); + const toggleDeactivateDialog = useCallback(() => toggleDialog('deactivate'), [toggleDialog]); + + const handleUsersStateChangeComplete = useCallback( + (response: any, isDelete: boolean = false): void => { + toggleDialog(undefined); // close dialog + if (!isDelete) { + loadUserDetails(); // reload to pickup new user state + } - onUserDeleteComplete = (response: any) => { - this.onUsersStateChangeComplete(response, true); - }; + onUsersStateChangeComplete?.(response, isDelete); + }, + [loadUserDetails, onUsersStateChangeComplete, toggleDialog] + ); - renderButtons() { - const { allowDelete, allowResetPassword } = this.props; - const { userProperties } = this.state; + const onUserDeleteComplete = useCallback( + (response: any) => { + handleUsersStateChangeComplete(response, true); + }, + [handleUsersStateChangeComplete] + ); + const renderButtons = (): React.ReactNode => { if (!userProperties) return null; const isActive = caseInsensitive(userProperties, 'active'); @@ -215,15 +210,20 @@ export class UserDetailsPanel extends React.PureComponent { <>
{allowResetPassword && isActive && ( - )} + {showResetTotp && isActive && ( + + )} {allowDelete && ( ); - } - - renderBody() { - const { - showGroupListLinks, - showPermissionListLinks, - currentUser, - policy, - rolesByUniqueName, - rootPolicy, - userId, - } = this.props; - const { loading, userProperties } = this.state; + }; + const renderBody = (): React.ReactNode => { if (loading) { return ; } @@ -282,8 +271,8 @@ export class UserDetailsPanel extends React.PureComponent { { } return
No user selected.
; - } + }; - renderHeader() { - const { loading, userProperties } = this.state; + const renderHeader = (): React.ReactNode => { if (loading || !userProperties) return 'User Details'; - const displayName = caseInsensitive(userProperties, 'displayName'); + const displayName_ = caseInsensitive(userProperties, 'displayName'); const active = caseInsensitive(userProperties, 'active'); return ( <> - {displayName} + {displayName_} {active !== undefined && ( { )} ); - } - - render() { - const { userId, allowDelete, allowResetPassword, toggleDetailsModal, onUsersStateChangeComplete } = this.props; - const { showDialog, userProperties } = this.state; - const { user, project } = getServerContext(); - const isSelf = userId === user.id; - - if (toggleDetailsModal) { - let footer: ReactNode; - if (user.isAdmin) { - // We do not currently support user management in sub folders, so we create the management URL for the project - // container. - const manageUrl = AppURL.create(ADMIN_KEY, 'users') - .addParams({ usersView: 'all', 'all.UserId~eq': userId }) - .setContainerPath(project.path); - - footer = ( - - Manage - - ); - } + }; - return ( - - {this.renderBody()} - + const { user, project } = getServerContext(); + const isSelfCtx = userId === user.id; + + if (toggleDetailsModal) { + let footer: ReactNode; + if (user.isAdmin) { + // We do not currently support user management in sub folders, so we create the management URL for the project + // container. + const manageUrl = AppURL.create(ADMIN_KEY, 'users') + .addParams({ usersView: 'all', 'all.UserId~eq': userId }) + .setContainerPath(project.path); + + footer = ( + + Manage + ); } return ( -
-
{this.renderHeader()}
-
- {this.renderBody()} - {!isSelf && onUsersStateChangeComplete && this.renderButtons()} - {allowResetPassword && showDialog === 'reset' && ( - - )} - {(showDialog === 'reactivate' || showDialog === 'deactivate') && ( - - )} - {allowDelete && showDialog === 'delete' && ( - - )} -
-
+ + {renderBody()} + ); } -} + + return ( +
+
{renderHeader()}
+
+ {renderBody()} + {!isSelfCtx && onUsersStateChangeComplete && renderButtons()} + {allowResetPassword && showDialog === 'reset' && ( + + )} + {showDialog === 'resetTotp' && ( + + )} + {(showDialog === 'reactivate' || showDialog === 'deactivate') && ( + + )} + {allowDelete && showDialog === 'delete' && ( + + )} +
+
+ ); +}; + +UserDetailsPanel.displayName = 'UserDetailsPanel'; diff --git a/packages/components/src/internal/components/user/UserResetTotpSettingsConfirmModal.test.tsx b/packages/components/src/internal/components/user/UserResetTotpSettingsConfirmModal.test.tsx new file mode 100644 index 0000000000..f8bef98d3e --- /dev/null +++ b/packages/components/src/internal/components/user/UserResetTotpSettingsConfirmModal.test.tsx @@ -0,0 +1,56 @@ +import React from 'react'; +import { userEvent } from '@testing-library/user-event'; + +import { renderWithAppContext } from '../../test/reactTestLibraryHelpers'; + +import { + UserResetTotpSettingsConfirmModal, + UserResetTotpSettingsConfirmModalProps, +} from './UserResetTotpSettingsConfirmModal'; + +describe('UserResetTotpSettingsConfirmModal', () => { + const email = 'jest@localhost.test'; + const displayName = 'Jest User'; + const userId = 5002; + const DEFAULT_PROPS: UserResetTotpSettingsConfirmModalProps = { + email, + displayName, + onCancel: jest.fn(), + onComplete: jest.fn(), + resetTotpSettingsApi: jest.fn().mockResolvedValue({ userId, resetTotpSettings: true }), + userId, + }; + + test('renders confirmation dialog', () => { + renderWithAppContext(); + + expect(document.querySelector('.modal-title').innerHTML).toEqual('Reset TOTP Settings?'); + expect(document.querySelector('.modal-body').innerHTML).toContain( + 'Are you sure you want to reset the TOTP settings for' + ); + expect(document.querySelector('.modal-body').innerHTML).toContain(displayName); + expect(document.querySelectorAll('.btn')).toHaveLength(2); + expect(document.querySelectorAll('.btn-success')).toHaveLength(1); + expect(document.querySelector('.btn-success').hasAttribute('disabled')).toBe(false); + }); + + test('calls resetTotpSettingsApi on confirm', async () => { + renderWithAppContext(); + + await userEvent.click(document.querySelector('.btn-success')); + + expect(DEFAULT_PROPS.resetTotpSettingsApi).toHaveBeenCalledWith(userId); + expect(DEFAULT_PROPS.onComplete).toHaveBeenCalled(); + }); + + test('with error', async () => { + const errorMsg = 'Test Error'; + const resetTotpSettingsApi = jest.fn().mockRejectedValue(errorMsg); + renderWithAppContext(); + + await userEvent.click(document.querySelector('.btn-success')); + + expect(resetTotpSettingsApi).toHaveBeenCalledWith(DEFAULT_PROPS.userId); + expect(DEFAULT_PROPS.onCancel).toHaveBeenCalled(); + }); +}); diff --git a/packages/components/src/internal/components/user/UserResetTotpSettingsConfirmModal.tsx b/packages/components/src/internal/components/user/UserResetTotpSettingsConfirmModal.tsx new file mode 100644 index 0000000000..590fe65758 --- /dev/null +++ b/packages/components/src/internal/components/user/UserResetTotpSettingsConfirmModal.tsx @@ -0,0 +1,54 @@ +import React, { FC, memo, useCallback, useState } from 'react'; + +import { resolveErrorMessage } from '../../util/messaging'; +import { Modal } from '../../Modal'; + +import { ResetTotpResponse, resetTotpSettings } from './actions'; +import { useNotificationsContext } from '../notifications/NotificationsContext'; + +export interface UserResetTotpSettingsConfirmModalProps { + email: string; + displayName: string; + onCancel: () => void; + onComplete: (response: ResetTotpResponse) => void; + resetTotpSettingsApi?: (userId: number) => Promise; + userId: number; +} + +export const UserResetTotpSettingsConfirmModal: FC = memo(props => { + const { email, displayName, userId, onCancel, onComplete, resetTotpSettingsApi = resetTotpSettings } = props; + const [submitting, setSubmitting] = useState(false); + const { createNotification } = useNotificationsContext(); + + const onConfirm = useCallback(async () => { + setSubmitting(true); + + try { + const resp = await resetTotpSettingsApi(userId); + onComplete({email, ...resp}); + } catch (e) { + const error = resolveErrorMessage(e, 'user', 'users', 'update') ?? 'Failed to reset TOTP settings'; + onCancel(); + createNotification( + { alertClass: 'danger', message: error }, + true + ); + } finally { + setSubmitting(false); + } + }, [email, userId, onComplete, resetTotpSettingsApi]); + + return ( + +

Are you sure you want to reset the TOTP settings for {displayName}?

+
+ ); +}); + +UserResetTotpSettingsConfirmModal.displayName = 'UserResetTotpSettingsConfirmModal'; diff --git a/packages/components/src/internal/components/user/actions.ts b/packages/components/src/internal/components/user/actions.ts index 8e93278fec..6fe2bf7cd6 100644 --- a/packages/components/src/internal/components/user/actions.ts +++ b/packages/components/src/internal/components/user/actions.ts @@ -1,6 +1,7 @@ import { OrderedMap } from 'immutable'; import { Ajax, Utils } from '@labkey/api'; +import { request } from '../../request'; import { buildURL } from '../../url/AppURL'; import { User } from '../base/models/User'; import { caseInsensitive } from '../../util/utils'; @@ -8,6 +9,7 @@ import { caseInsensitive } from '../../util/utils'; import { formatDate, parseDate } from '../../util/Date'; import { ChangePasswordModel } from './models'; +import { hasModule } from '../../app/utils'; export function getUserProperties(userId: number): Promise { return new Promise((resolve, reject) => { @@ -156,3 +158,36 @@ export function resetPassword(userId: number): Promise { }); }); } + +export async function hasTotpSettings(userId: number): Promise { + if (!hasModule('mfa')) { + return false; + } + + const response = await request<{ hasTotpSettings: boolean }>({ + url: buildURL('totp', 'hasTotpSettings.api', { userId }), + }); + return response.hasTotpSettings; +} + +export type ResetTotpResponse = { + email?: string; + resetTotpSettings: boolean; + userId: number; +}; + +export async function resetTotpSettings(userId: number): Promise { + const response = await request<{ success: boolean }>({ + url: buildURL('totp', 'resetTotpSettingsApi.api'), + method: 'POST', + params: {userId}, + }); + + if (!response.success) { + const errorLogMsg = `Unable to reset TOTP settings for user: ${userId}`; + console.error(errorLogMsg, response); + throw new Error(errorLogMsg); + } + + return {userId, resetTotpSettings: true}; +}