diff --git a/src/apps/admin/src/lib/components/DialogEditUserEmail/DialogEditUserEmail.module.scss b/src/apps/admin/src/lib/components/DialogEditUserEmail/DialogEditUserEmail.module.scss index 917d059dd..2104049c7 100644 --- a/src/apps/admin/src/lib/components/DialogEditUserEmail/DialogEditUserEmail.module.scss +++ b/src/apps/admin/src/lib/components/DialogEditUserEmail/DialogEditUserEmail.module.scss @@ -5,6 +5,13 @@ position: relative; } +.blockForm { + display: flex; + flex-direction: column; + gap: 20px; + position: relative; +} + .actionButtons { display: flex; justify-content: flex-end; diff --git a/src/apps/admin/src/lib/components/DialogEditUserEmail/DialogEditUserEmail.tsx b/src/apps/admin/src/lib/components/DialogEditUserEmail/DialogEditUserEmail.tsx index 81157ab45..1ea8baac3 100644 --- a/src/apps/admin/src/lib/components/DialogEditUserEmail/DialogEditUserEmail.tsx +++ b/src/apps/admin/src/lib/components/DialogEditUserEmail/DialogEditUserEmail.tsx @@ -67,7 +67,7 @@ export const DialogEditUserEmail: FC = (props: Props) => { className={classNames(styles.container, props.className)} onSubmit={handleSubmit(onSubmit)} > -
+
void + userInfo: UserInfo + providers: SSOLoginProvider[] +} + +export const DialogEditUserSSOLogin: FC = (props: Props) => { + const { width: screenWidth }: WindowSize = useWindowSize() + const isTablet = useMemo(() => screenWidth <= 900, [screenWidth]) + const { + ssoUserLogins, + isLoading: isFetching, + isAdding, + isRemoving, + doAddSSOUserLogin, + doUpdateSSOUserLogin, + doRemoveSSOUserLogin, + }: useManageUserSSOLoginProps = useManageUserSSOLogin(props.userInfo) + const isRemovingBool = useMemo( + () => _.some(isRemoving, value => value === true), + [isRemoving], + ) + const isLoading = useMemo( + () => isFetching || isAdding || isRemovingBool, + [isFetching, isAdding, isRemovingBool], + ) + + const [showAddForm, setShowAddForm] = useState(false) + const [showEditForm, setShowEditForm] = useState() + + const handleClose = useCallback(() => { + if (!isLoading) { + props.setOpen(false) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isLoading]) + const columns = useMemo[]>( + () => [ + { + className: styles.tableCell, + label: 'User ID', + propertyName: 'userId', + type: 'text', + }, + { + className: styles.tableCell, + label: 'Name', + propertyName: 'name', + type: 'text', + }, + { + className: styles.tableCell, + label: 'Provider', + propertyName: 'provider', + type: 'text', + }, + { + className: styles.tableCell, + label: 'Email', + propertyName: 'email', + type: 'text', + }, + { + className: styles.blockAction, + label: 'Actions', + renderer: (data: SSOUserLogin) => ( +
+
+ ), + type: 'action', + }, + ], + [isAdding, isRemoving, doRemoveSSOUserLogin], + ) + + const columnsMobile = useMemo[][]>( + () => columns.map(column => { + if (column.label === 'Actions') { + return [ + { + ...column, + colSpan: 2, + mobileType: 'last-value', + }, + ] + } + + return [ + { + ...column, + className: '', + label: `${column.label as string} label`, + mobileType: 'label', + renderer: () => ( +
+ {column.label as string} + : +
+ ), + type: 'element', + }, + { + ...column, + mobileType: 'last-value', + }, + ] + }), + [columns], + ) + + const cancelEditForm = useCallback(() => { + setShowEditForm(undefined) + setShowAddForm(false) + }, [setShowAddForm, setShowEditForm]) + + return ( + +
+ {isFetching ? ( +
+ +
+ ) : ( + <> + {ssoUserLogins.length ? ( + <> + {isTablet ? ( + + ) : ( + + )} + + ) : ( +
No SSO logins
+ )} + + )} + + {showAddForm || showEditForm ? ( + + ) : ( + + )} +
+ +
+ + {(isAdding || isRemovingBool) && ( +
+ +
+ )} + + + ) +} + +export default DialogEditUserSSOLogin diff --git a/src/apps/admin/src/lib/components/DialogEditUserSSOLogin/index.ts b/src/apps/admin/src/lib/components/DialogEditUserSSOLogin/index.ts new file mode 100644 index 000000000..5c5b5b91d --- /dev/null +++ b/src/apps/admin/src/lib/components/DialogEditUserSSOLogin/index.ts @@ -0,0 +1 @@ +export { default as DialogEditUserSSOLogin } from './DialogEditUserSSOLogin' diff --git a/src/apps/admin/src/lib/components/FormAddSSOLogin/FormAddSSOLogin.module.scss b/src/apps/admin/src/lib/components/FormAddSSOLogin/FormAddSSOLogin.module.scss new file mode 100644 index 000000000..e20915d75 --- /dev/null +++ b/src/apps/admin/src/lib/components/FormAddSSOLogin/FormAddSSOLogin.module.scss @@ -0,0 +1,13 @@ +.container { + display: flex; + flex-direction: column; + gap: 20px; + position: relative; + width: 100%; +} + +.actionButtons { + display: flex; + justify-content: flex-end; + gap: 6px; +} diff --git a/src/apps/admin/src/lib/components/FormAddSSOLogin/FormAddSSOLogin.tsx b/src/apps/admin/src/lib/components/FormAddSSOLogin/FormAddSSOLogin.tsx new file mode 100644 index 000000000..289b41cee --- /dev/null +++ b/src/apps/admin/src/lib/components/FormAddSSOLogin/FormAddSSOLogin.tsx @@ -0,0 +1,173 @@ +/** + * Form Add SSO Login. + */ +import { FC, useCallback, useEffect, useMemo } from 'react' +import { + Controller, + ControllerRenderProps, + useForm, + UseFormReturn, +} from 'react-hook-form' +import _ from 'lodash' +import classNames from 'classnames' + +import { Button, InputSelectReact, InputText } from '~/libs/ui' +import { yupResolver } from '@hookform/resolvers/yup' + +import { FormAddSSOLoginData } from '../../models/FormAddSSOLoginData.model' +import { SSOLoginProvider, SSOUserLogin } from '../../models' +import { formAddSSOLoginSchema } from '../../utils' + +import styles from './FormAddSSOLogin.module.scss' + +interface Props { + className?: string + isAdding: boolean + onSubmit: (data: FormAddSSOLoginData) => void + onCancel: () => void + providers: SSOLoginProvider[] + editingData?: SSOUserLogin +} + +export const FormAddSSOLogin: FC = (props: Props) => { + const isEditing = !!props.editingData + const providerOptions = useMemo( + () => props.providers.map(item => ({ + label: item.name, + value: item.name, + })), + [props.providers], + ) + const { + register, + handleSubmit, + control, + reset, + formState: { errors, isValid, isDirty }, + }: UseFormReturn = useForm({ + defaultValues: { + email: '', + name: '', + provider: '', + userId: '', + }, + mode: 'all', + resolver: yupResolver(formAddSSOLoginSchema), + }) + const onSubmit = useCallback( + (data: FormAddSSOLoginData) => { + props.onSubmit(data) + }, + [props.onSubmit], + ) + + useEffect(() => { + if (props.editingData) { + reset({ + email: props.editingData.email, + name: props.editingData.name, + provider: props.editingData.provider, + userId: props.editingData.userId, + }) + } + }, [props.editingData]) + + return ( +
+ + {isEditing ? 'Edit' : 'Add'} + {' '} + SSO Login + +
+ + + + }) { + return ( + + ) + }} + /> + +
+
+ + +
+ + ) +} + +export default FormAddSSOLogin diff --git a/src/apps/admin/src/lib/components/FormAddSSOLogin/index.ts b/src/apps/admin/src/lib/components/FormAddSSOLogin/index.ts new file mode 100644 index 000000000..6a538903b --- /dev/null +++ b/src/apps/admin/src/lib/components/FormAddSSOLogin/index.ts @@ -0,0 +1 @@ +export { default as FormAddSSOLogin } from './FormAddSSOLogin' diff --git a/src/apps/admin/src/lib/components/UsersTable/UsersTable.tsx b/src/apps/admin/src/lib/components/UsersTable/UsersTable.tsx index 4ae53a4cf..aa65b2dee 100644 --- a/src/apps/admin/src/lib/components/UsersTable/UsersTable.tsx +++ b/src/apps/admin/src/lib/components/UsersTable/UsersTable.tsx @@ -21,15 +21,18 @@ import { CopyButton } from '../CopyButton' import { DialogEditUserEmail } from '../DialogEditUserEmail' import { DialogEditUserRoles } from '../DialogEditUserRoles' import { DialogEditUserGroups } from '../DialogEditUserGroups' +import { DialogEditUserSSOLogin } from '../DialogEditUserSSOLogin' import { DialogEditUserTerms } from '../DialogEditUserTerms' import { DialogEditUserStatus } from '../DialogEditUserStatus' import { DialogUserStatusHistory } from '../DialogUserStatusHistory' import { DropdownMenuButton } from '../common/DropdownMenuButton' -import { useTableFilterLocal, useTableFilterLocalProps } from '../../hooks' +import { useOnComponentDidMount, useTableFilterLocal, useTableFilterLocalProps } from '../../hooks' import { TABLE_DATE_FORMAT } from '../../../config/index.config' -import { UserInfo } from '../../models' +import { SSOLoginProvider, UserInfo } from '../../models' import { Pagination } from '../common/Pagination' import { ReactComponent as RectangleListRegularIcon } from '../../assets/i/rectangle-list-regular-icon.svg' +import { fetchSSOLoginProviders } from '../../services' +import { handleError } from '../../utils' import styles from './UsersTable.module.scss' @@ -47,6 +50,7 @@ interface Props { export const UsersTable: FC = props => { const [colWidth, setColWidth] = useState({}) + const [ssoLoginProviders, setSsoLoginProviders] = useState([]) const [showDialogEditUserEmail, setShowDialogEditUserEmail] = useState< UserInfo | undefined >() @@ -56,6 +60,9 @@ export const UsersTable: FC = props => { const [showDialogEditUserGroups, setShowDialogEditUserGroups] = useState< UserInfo | undefined >() + const [showDialogEditUserSSOLogin, setShowDialogEditSSOLogin] = useState< + UserInfo | undefined + >() const [showDialogEditUserTerms, setShowDialogEditUserTerms] = useState< UserInfo | undefined >() @@ -273,6 +280,8 @@ export const UsersTable: FC = props => { setShowDialogEditUserGroups(data) } else if (item === 'Terms') { setShowDialogEditUserTerms(data) + } else if (item === 'SSO Logins') { + setShowDialogEditSSOLogin(data) } else if (item === 'Deactivate') { setShowDialogEditUserStatus(data) } else if (item === 'Activate') { @@ -298,6 +307,7 @@ export const UsersTable: FC = props => { 'Roles', 'Groups', 'Terms', + 'SSO Logins', ...(data.active ? ['Deactivate'] : ['Activate']), @@ -318,6 +328,7 @@ export const UsersTable: FC = props => { 'Roles', 'Groups', 'Terms', + 'SSO Logins', ]} onSelectOption={onSelectOption} > @@ -359,6 +370,16 @@ export const UsersTable: FC = props => { [isTablet, isMobile], ) + useOnComponentDidMount(() => { + fetchSSOLoginProviders() + .then(result => { + setSsoLoginProviders(result) + }) + .catch(e => { + handleError(e) + }) + }) + return (
= props => { userInfo={showDialogEditUserGroups} /> )} + {showDialogEditUserSSOLogin && ( + + )} {showDialogEditUserTerms && ( { + switch (action.type) { + case SSOUserLoginsActionType.FETCH_SSO_USER_LOGINS_INIT: { + return { + ...previousState, + isLoading: true, + ssoUserLogins: [], + } + } + + case SSOUserLoginsActionType.FETCH_SSO_USER_LOGINS_DONE: { + return { + ...previousState, + isLoading: false, + providers: action.payload.providers, + ssoUserLogins: action.payload.ssoUserLogins, + } + } + + case SSOUserLoginsActionType.FETCH_SSO_USER_LOGINS_FAILED: { + return { + ...previousState, + isLoading: false, + } + } + + case SSOUserLoginsActionType.ADD_SSO_USER_LOGIN_INIT: { + return { + ...previousState, + isAdding: true, + } + } + + case SSOUserLoginsActionType.ADD_SSO_USER_LOGIN_DONE: { + return { + ...previousState, + isAdding: false, + ssoUserLogins: [...previousState.ssoUserLogins, action.payload], + } + } + + case SSOUserLoginsActionType.ADD_SSO_USER_LOGIN_FAILED: { + return { + ...previousState, + isAdding: false, + } + } + + case SSOUserLoginsActionType.UPDATE_SSO_USER_LOGIN_INIT: { + return { + ...previousState, + isAdding: true, + } + } + + case SSOUserLoginsActionType.UPDATE_SSO_USER_LOGIN_DONE: { + return { + ...previousState, + isAdding: false, + ssoUserLogins: previousState.ssoUserLogins.map( + item => (item.provider === action.payload.provider + ? action.payload + : item), + ), + } + } + + case SSOUserLoginsActionType.UPDATE_SSO_USER_LOGIN_FAILED: { + return { + ...previousState, + isAdding: false, + } + } + + case SSOUserLoginsActionType.REMOVE_SSO_USER_LOGIN_INIT: { + return { + ...previousState, + isRemoving: { + ...previousState.isRemoving, + [action.payload]: true, + }, + } + } + + case SSOUserLoginsActionType.REMOVE_SSO_USER_LOGIN_DONE: { + const ssoUserLogins = _.filter( + previousState.ssoUserLogins, + item => item.provider !== action.payload, + ) + return { + ...previousState, + isRemoving: { + ...previousState.isRemoving, + [action.payload]: false, + }, + ssoUserLogins, + } + } + + case SSOUserLoginsActionType.REMOVE_SSO_USER_LOGIN_FAILED: { + return { + ...previousState, + isRemoving: { + ...previousState.isRemoving, + [action.payload]: false, + }, + } + } + + default: { + return previousState + } + } +} + +export interface useManageUserSSOLoginProps { + isLoading: boolean + isRemoving: { [key: string]: boolean } + isAdding: boolean + ssoUserLogins: SSOUserLogin[] + doAddSSOUserLogin: ( + formData: FormAddSSOLoginData, + onSuccess: () => void, + ) => void + doUpdateSSOUserLogin: ( + formData: FormAddSSOLoginData, + onSuccess: () => void, + ) => void + doRemoveSSOUserLogin: (ssoUserLogin: SSOUserLogin) => void +} + +/** + * Manage sso user logins redux state + * @param userInfo user info + * @returns state data + */ +export function useManageUserSSOLogin( + userInfo: UserInfo, +): useManageUserSSOLoginProps { + const [state, dispatch] = useReducer(reducer, { + isAdding: false, + isLoading: false, + isRemoving: {}, + providers: [], + ssoUserLogins: [], + }) + + const doFetchSSOUserLogins = useCallback(() => { + dispatch({ + type: SSOUserLoginsActionType.FETCH_SSO_USER_LOGINS_INIT, + }) + Promise.all([fetchSSOUserLogins(userInfo.id), fetchSSOLoginProviders()]) + .then(result => { + dispatch({ + payload: { + providers: result[1], + ssoUserLogins: result[0], + }, + type: SSOUserLoginsActionType.FETCH_SSO_USER_LOGINS_DONE, + }) + }) + .catch(e => { + dispatch({ + type: SSOUserLoginsActionType.FETCH_SSO_USER_LOGINS_FAILED, + }) + handleError(e) + }) + }, [dispatch, userInfo]) + + const doAddSSOUserLogin = useCallback( + (formData: FormAddSSOLoginData, onSuccess: () => void) => { + dispatch({ + type: SSOUserLoginsActionType.ADD_SSO_USER_LOGIN_INIT, + }) + createSSOUserLogin(userInfo.id, formData) + .then(result => { + toast.success('SSO login added successfully', { + toastId: 'Add sso login', + }) + dispatch({ + payload: result, + type: SSOUserLoginsActionType.ADD_SSO_USER_LOGIN_DONE, + }) + onSuccess() + }) + .catch(e => { + dispatch({ + type: SSOUserLoginsActionType.ADD_SSO_USER_LOGIN_FAILED, + }) + handleError(e) + }) + }, + [dispatch, userInfo], + ) + + const doUpdateSSOUserLogin = useCallback( + (formData: FormAddSSOLoginData, onSuccess: () => void) => { + dispatch({ + type: SSOUserLoginsActionType.UPDATE_SSO_USER_LOGIN_INIT, + }) + updateSSOUserLogin(userInfo.id, formData) + .then(result => { + toast.success('SSO login updated successfully', { + toastId: 'Update sso login', + }) + dispatch({ + payload: result, + type: SSOUserLoginsActionType.UPDATE_SSO_USER_LOGIN_DONE, + }) + onSuccess() + }) + .catch(e => { + dispatch({ + type: SSOUserLoginsActionType.UPDATE_SSO_USER_LOGIN_FAILED, + }) + handleError(e) + }) + }, + [dispatch, userInfo], + ) + + const doRemoveSSOUserLogin = useCallback( + (ssoUserLogin: SSOUserLogin) => { + dispatch({ + payload: ssoUserLogin.provider, + type: SSOUserLoginsActionType.REMOVE_SSO_USER_LOGIN_INIT, + }) + deleteSSOUserLogin(userInfo.id, ssoUserLogin.provider) + .then(() => { + toast.success('SSO login removed successfully', { + toastId: 'Remove sso login', + }) + dispatch({ + payload: ssoUserLogin.provider, + type: SSOUserLoginsActionType.REMOVE_SSO_USER_LOGIN_DONE, + }) + }) + .catch(e => { + dispatch({ + payload: ssoUserLogin.provider, + type: SSOUserLoginsActionType.REMOVE_SSO_USER_LOGIN_FAILED, + }) + handleError(e) + }) + }, + [dispatch, userInfo], + ) + + useOnComponentDidMount(() => { + doFetchSSOUserLogins() + }) + + return { + doAddSSOUserLogin, + doRemoveSSOUserLogin, + doUpdateSSOUserLogin, + isAdding: state.isAdding, + isLoading: state.isLoading, + isRemoving: state.isRemoving, + ssoUserLogins: state.ssoUserLogins, + } +} diff --git a/src/apps/admin/src/lib/models/FormAddSSOLoginData.model.ts b/src/apps/admin/src/lib/models/FormAddSSOLoginData.model.ts new file mode 100644 index 000000000..0ce1ea9c7 --- /dev/null +++ b/src/apps/admin/src/lib/models/FormAddSSOLoginData.model.ts @@ -0,0 +1,9 @@ +/** + * Model for add sso login data + */ +export interface FormAddSSOLoginData { + userId: string + name: string + email: string + provider: string +} diff --git a/src/apps/admin/src/lib/models/SSOLoginProvider.model.ts b/src/apps/admin/src/lib/models/SSOLoginProvider.model.ts new file mode 100644 index 000000000..f3f75c17f --- /dev/null +++ b/src/apps/admin/src/lib/models/SSOLoginProvider.model.ts @@ -0,0 +1,8 @@ +/** + * Model for sso login provider + */ +export interface SSOLoginProvider { + ssoLoginProviderId: number + name: string + type: string +} diff --git a/src/apps/admin/src/lib/models/SSOUserLogin.model.ts b/src/apps/admin/src/lib/models/SSOUserLogin.model.ts new file mode 100644 index 000000000..c80e19f01 --- /dev/null +++ b/src/apps/admin/src/lib/models/SSOUserLogin.model.ts @@ -0,0 +1,14 @@ +/** + * Model for sso user login + */ +export interface SSOUserLogin { + userId: string + name: string + email: string + providerType: string + provider: string + context: any + social: boolean + enterprise: boolean + emailVerified: boolean +} diff --git a/src/apps/admin/src/lib/models/index.ts b/src/apps/admin/src/lib/models/index.ts index 786d0dc93..d85a6e63a 100644 --- a/src/apps/admin/src/lib/models/index.ts +++ b/src/apps/admin/src/lib/models/index.ts @@ -33,6 +33,8 @@ export * from './RoleMemberInfo.model' export * from './Submission.model' export * from './RequestBusAPI.model' export * from './MemberSubmission.model' +export * from './SSOUserLogin.model' +export * from './SSOLoginProvider.model' export * from './FormAddGroupMembers.type' export * from './TableFilterType.type' export * from './TableRolesFilter.type' diff --git a/src/apps/admin/src/lib/services/user.service.ts b/src/apps/admin/src/lib/services/user.service.ts index 73920a830..4f1dcab54 100644 --- a/src/apps/admin/src/lib/services/user.service.ts +++ b/src/apps/admin/src/lib/services/user.service.ts @@ -1,15 +1,18 @@ import _ from 'lodash' import { EnvironmentConfig } from '~/config' -import { xhrGetAsync, xhrPatchAsync } from '~/libs/core' +import { xhrDeleteAsync, xhrGetAsync, xhrPatchAsync, xhrPostAsync, xhrPutAsync } from '~/libs/core' import { adjustUserInfoResponse, adjustUserStatusHistoryResponse, ApiV3Response, + SSOLoginProvider, + SSOUserLogin, UserInfo, UserStatusHistory, } from '../models' +import { FormAddSSOLoginData } from '../models/FormAddSSOLoginData.model' /** * Gets the member suggest by handle. @@ -154,3 +157,84 @@ export const findUserById = async (userId: string | number): Promise = ) return adjustUserInfoResponse(result.result.content) } + +/** + * Fetch list of sso user login. + * @param userId user id. + * @returns resolves to sso user logins + */ +export const fetchSSOUserLogins = async (userId: string | number): Promise => { + const result = await xhrGetAsync>( + `${EnvironmentConfig.API.V3}/users/${userId}/SSOUserLogins`, + ) + return result.result.content +} + +/** + * Fetch list of sso login provider. + * @returns resolves to sso user logins + */ +export const fetchSSOLoginProviders = async (): Promise => { + const result = await xhrGetAsync>( + `${EnvironmentConfig.API.V3}/ssoLoginProviders`, + ) + return result.result.content +} + +/** + * Create sso user login. + * @param userId user id. + * @param userLogin user login info. + * @returns resolves to sso user login + */ +export const createSSOUserLogin = async ( + userId: string | number, + userLogin: FormAddSSOLoginData, +): Promise => { + const result = await xhrPostAsync< + { + param: FormAddSSOLoginData + }, + ApiV3Response + >(`${EnvironmentConfig.API.V3}/users/${userId}/SSOUserLogin`, { + param: userLogin, + }) + return result.result.content +} + +/** + * Update sso user login. + * @param userId user id. + * @param userLogin user login info. + * @returns resolves to sso user login + */ +export const updateSSOUserLogin = async ( + userId: string | number, + userLogin: FormAddSSOLoginData, +): Promise => { + const result = await xhrPutAsync< + { + param: FormAddSSOLoginData + }, + ApiV3Response + >(`${EnvironmentConfig.API.V3}/users/${userId}/SSOUserLogin`, { + param: userLogin, + }) + return result.result.content +} + +/** + * Delete sso user login. + * @param userId user id. + * @param provider login provider. + * @returns resolves to sso user login + */ +export const deleteSSOUserLogin = async ( + userId: string | number, + provider: string, +): Promise => { + const result = await xhrDeleteAsync>( + `${EnvironmentConfig.API.V3}/users/${userId}/SSOUserLogin?provider=${provider}`, + ) + return result.result.content +} diff --git a/src/apps/admin/src/lib/utils/validation.ts b/src/apps/admin/src/lib/utils/validation.ts index 89e34212a..051761582 100644 --- a/src/apps/admin/src/lib/utils/validation.ts +++ b/src/apps/admin/src/lib/utils/validation.ts @@ -20,6 +20,7 @@ import { } from '../models' import { FormEditUserStatus } from '../models/FormEditUserStatus.model' import { FormAddRoleMembers } from '../models/FormAddRoleMembers.type' +import { FormAddSSOLoginData } from '../models/FormAddSSOLoginData.model' /** * validation schema for form filter users @@ -366,6 +367,26 @@ export const formEditUserEmailSchema: Yup.ObjectSchema .required('Email address is required.'), }) +/** + * validation schema for form edit sso user login + */ +export const formAddSSOLoginSchema: Yup.ObjectSchema + = Yup.object({ + email: Yup.string() + .trim() + .email('Invalid email address.') + .required('Email address is required.'), + name: Yup.string() + .trim() + .required('Name is required.'), + provider: Yup.string() + .trim() + .required('Provider is required.'), + userId: Yup.string() + .trim() + .required('User id is required.'), + }) + /** * validation schema for form add group */