diff --git a/src/api/index.ts b/src/api/index.ts index 2c3361e..dd4e948 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -57,6 +57,11 @@ export async function deleteAccount(username: string, password: string) { await client.deleteAccount({ username, password }); } +// ChangePassword changes the password. +export async function ChangePassword(username: string, password: string, newPassword: string) { + await client.changePassword({ username, currentPassword: password, newPassword }); +} + // createProject creates a new project. export async function createProject(name: string): Promise { const res = await client.createProject({ name }); diff --git a/src/assets/styles/pages/admin_setting_account.scss b/src/assets/styles/pages/admin_setting_account.scss index 969f10a..13ed8f7 100644 --- a/src/assets/styles/pages/admin_setting_account.scss +++ b/src/assets/styles/pages/admin_setting_account.scss @@ -281,4 +281,14 @@ } } } + + .change_password_form { + .modal_desc { + margin-bottom: 8px; + } + + .input_box { + margin-top: 8px; + } + } } diff --git a/src/components/Button/Button.tsx b/src/components/Button/Button.tsx index ceb172f..f5ea8ee 100644 --- a/src/components/Button/Button.tsx +++ b/src/components/Button/Button.tsx @@ -26,6 +26,7 @@ const ButtonStyle = { primary: 'orange_0', success: 'green_0', danger: 'red_0', + info: 'blue_0', toggle: 'btn_toggle', disabled: 'btn_line gray300', default: undefined, @@ -42,7 +43,7 @@ type ButtonProps = { icon?: ReactNode; size?: 'sm' | 'md' | 'lg'; outline?: boolean; - color?: 'primary' | 'success' | 'danger' | 'toggle' | 'default'; + color?: 'primary' | 'success' | 'danger' | 'info' | 'toggle' | 'default'; isActive?: boolean; buttonRef?: any; } & AnchorHTMLAttributes & diff --git a/src/components/Icons/Icon.tsx b/src/components/Icons/Icon.tsx index 91debad..125158c 100644 --- a/src/components/Icons/Icon.tsx +++ b/src/components/Icons/Icon.tsx @@ -59,6 +59,7 @@ import PreviousSVG from 'assets/icons/icon_previous.svg?react'; import NextSVG from 'assets/icons/icon_next.svg?react'; import DiscordSVG from 'assets/icons/icon_discord.svg?react'; import MoreLargeSVG from 'assets/icons/icon_more_large.svg?react'; +import RepeatSVG from 'assets/icons/icon_repeat.svg?react'; const svgMap = { shortcut: , @@ -104,6 +105,7 @@ const svgMap = { next: , discord: , moreLarge: , + repeat: , }; type SVGType = keyof typeof svgMap; diff --git a/src/features/users/Account.tsx b/src/features/users/Account.tsx new file mode 100644 index 0000000..46c1844 --- /dev/null +++ b/src/features/users/Account.tsx @@ -0,0 +1,174 @@ +/* + * Copyright 2024 The Yorkie Authors. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { useCallback, useState, useEffect } from 'react'; +import { Button, Icon, Modal, InputTextBox } from 'components'; +import { selectUsers, ChangePasswordFields, changePassword } from './usersSlice'; +import { useAppDispatch, useAppSelector } from 'app/hooks'; +import { useForm } from 'react-hook-form'; + +export function Account() { + const dispatch = useAppDispatch(); + const [isModalOpen, setIsModalOpen] = useState(false); + const { + username, + changePassword: { isSuccess, status, error }, + } = useAppSelector(selectUsers); + + const { + register, + watch, + formState: { errors: formErrors }, + handleSubmit, + setError, + trigger, + reset, + } = useForm>(); + + const onSubmit = useCallback( + (data: Omit) => { + dispatch(changePassword({ ...data, username })); + }, + [dispatch], + ); + + const onClose = useCallback(() => { + reset(); + setIsModalOpen(false); + }, [reset, setIsModalOpen]); + + useEffect(() => { + if (!error) return; + const { target, message } = error; + setError(target, { type: 'custom', message: message }, { shouldFocus: true }); + }, [error, setError]); + + return ( +
+
+ Account +
+
+
Change Password
+
+

+ Update your current password to enhance account security. +
+ Choose a strong, unique password that you don't use for other accounts. +

+
+ +
+
+
+ {isModalOpen && ( + + + + +
+ + Change Password + To change your password, please fill in the fields below. + + { + await trigger(['password']); + }, + })} + state={formErrors.password ? 'error' : 'normal'} + helperText={(formErrors.password && formErrors.password.message) || ''} + /> + ,./])(?:[a-zA-Z0-9~`!?@#$%^&*()\-_+={}[\]|\\;:'"<>,./]{8,30})$/, + message: + 'Password must contain 8 to 30 characters with at least 1 alphabet, 1 number, and 1 special character', + }, + validate: (value) => + value !== watch('password') || + 'New password cannot be the same as your current password. Please choose a different password.', + onChange: async () => { + await trigger(['newPassword', 'confirmPassword']); + }, + })} + state={formErrors.newPassword ? 'error' : 'normal'} + helperText={(formErrors.newPassword && formErrors.newPassword.message) || ''} + /> + value === watch('newPassword') || 'Passwords do not match', + onChange: async () => { + await trigger('confirmPassword'); + }, + })} + state={formErrors.confirmPassword ? 'error' : 'normal'} + helperText={(formErrors.confirmPassword && formErrors.confirmPassword.message) || ''} + /> + + + + + + + + + +
+ )} +
+ ); +} diff --git a/src/features/users/usersSlice.ts b/src/features/users/usersSlice.ts index 2ab4924..5033a02 100644 --- a/src/features/users/usersSlice.ts +++ b/src/features/users/usersSlice.ts @@ -46,6 +46,14 @@ export interface UsersState { isSuccess: boolean; error: { message: string } | null; }; + changePassword: { + status: 'idle' | 'loading' | 'failed'; + isSuccess: boolean; + error: { + target: 'password' | 'newPassword'; + message: string; + } | null; + }; preferences: { theme: { useSystem: boolean; @@ -75,6 +83,13 @@ export type SignupFields = { confirmPassword: string; }; +export type ChangePasswordFields = { + username: string; + password: string; + newPassword: string; + confirmPassword: string; +}; + type JWTPayload = { username: string; }; @@ -96,6 +111,11 @@ const initialState: UsersState = { status: 'idle', error: null, }, + changePassword: { + isSuccess: false, + status: 'idle', + error: null, + }, signup: { isSuccess: false, status: 'idle', @@ -137,6 +157,13 @@ export const deleteUser = createAppThunk('users/deleteAccount return await api.deleteAccount(username, password); }); +export const changePassword = createAppThunk( + 'users/changePassword', + async ({ username, password, newPassword }) => { + return await api.ChangePassword(username, password, newPassword); + }, +); + export const usersSlice = createSlice({ name: 'users', initialState, @@ -266,6 +293,45 @@ export const usersSlice = createSlice({ state.username = ''; state.logout.isSuccess = true; }); + builder.addCase(changePassword.pending, (state) => { + state.changePassword.status = 'loading'; + }); + builder.addCase(changePassword.rejected, (state, action) => { + state.changePassword.status = 'failed'; + const error = action.payload!.error; + if (!(error instanceof RPCError)) { + return; + } + const statusCode = Number(error.code); + if (statusCode === RPCStatusCode.UNAUTHENTICATED) { + state.changePassword.error = { + target: 'password', + message: 'The password is incorrect. Try again.', + }; + action.meta.isHandledError = true; + return; + } else if (statusCode === RPCStatusCode.INVALID_ARGUMENT) { + for (const { field, description } of error.details!) { + if (field === 'Password') { + state.changePassword.error = { + target: 'newPassword', + message: description, + }; + } + } + action.meta.isHandledError = true; + } + }); + builder.addCase(changePassword.fulfilled, (state) => { + state.changePassword.status = 'idle'; + state.changePassword.isSuccess = true; + localStorage.removeItem('token'); + api.setToken(''); + state.token = ''; + state.isValidToken = false; + state.username = ''; + state.logout.isSuccess = true; + }); builder.addCase(deleteUser.pending, (state) => { state.deleteAccount.status = 'loading'; }); diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx index c0180f3..7299927 100644 --- a/src/pages/SettingsPage.tsx +++ b/src/pages/SettingsPage.tsx @@ -20,6 +20,7 @@ import { PageTemplate } from './PageTemplate'; import { Button, Icon, Navigator } from 'components'; import { Preferences } from 'features/users/Preferences'; import { DangerZone } from 'features/users/DangerZone'; +import { Account } from 'features/users/Account'; export function SettingsPage() { const navigate = useNavigate(); @@ -35,11 +36,13 @@ export function SettingsPage() {
+