Skip to content

Commit

Permalink
Add account password change feature
Browse files Browse the repository at this point in the history
  • Loading branch information
chacha912 committed Oct 1, 2024
1 parent 4fb5588 commit 80cfea3
Show file tree
Hide file tree
Showing 7 changed files with 262 additions and 1 deletion.
5 changes: 5 additions & 0 deletions src/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Project> {
const res = await client.createProject({ name });
Expand Down
10 changes: 10 additions & 0 deletions src/assets/styles/pages/admin_setting_account.scss
Original file line number Diff line number Diff line change
Expand Up @@ -281,4 +281,14 @@
}
}
}

.change_password_form {
.modal_desc {
margin-bottom: 8px;
}

.input_box {
margin-top: 8px;
}
}
}
3 changes: 2 additions & 1 deletion src/components/Button/Button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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<HTMLAnchorElement> &
Expand Down
2 changes: 2 additions & 0 deletions src/components/Icons/Icon.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: <ShortcutSVG />,
Expand Down Expand Up @@ -104,6 +105,7 @@ const svgMap = {
next: <NextSVG />,
discord: <DiscordSVG />,
moreLarge: <MoreLargeSVG />,
repeat: <RepeatSVG />,
};
type SVGType = keyof typeof svgMap;

Expand Down
174 changes: 174 additions & 0 deletions src/features/users/Account.tsx
Original file line number Diff line number Diff line change
@@ -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<Omit<ChangePasswordFields, 'username'>>();

const onSubmit = useCallback(
(data: Omit<ChangePasswordFields, 'username'>) => {
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 (
<div className="setting_box" id="danger">
<div className="setting_title">
<strong className="text">Account</strong>
</div>
<dl className="sub_info">
<dt className="sub_title">Change Password</dt>
<dd className="sub_desc">
<p className="guide">
Update your current password to enhance account security.
<br />
Choose a strong, unique password that you don't use for other accounts.
</p>
<div className="btn_box">
<Button
color="primary"
icon={<Icon type="repeat" />}
onClick={() => {
setIsModalOpen(true);
}}
>
Change password
</Button>
</div>
</dd>
</dl>
{isModalOpen && (
<Modal>
<Modal.Top>
<Icon type="lockSmall" className="blue_dark" />
</Modal.Top>
<form className="form change_password_form" onSubmit={handleSubmit(onSubmit)}>
<Modal.Content>
<Modal.Title>Change Password</Modal.Title>
<Modal.Description>To change your password, please fill in the fields below.</Modal.Description>
<input hidden type="text" defaultValue={username} autoComplete="username"></input>
<InputTextBox
type="password"
label="Current password"
autoFocus
blindLabel={true}
floatingLabel={true}
autoComplete="new-password"
placeholder="Current password"
fullWidth
{...register('password', {
required: 'Password is required',
onChange: async () => {
await trigger(['password']);
},
})}
state={formErrors.password ? 'error' : 'normal'}
helperText={(formErrors.password && formErrors.password.message) || ''}
/>
<InputTextBox
type="password"
label="New password"
blindLabel={true}
floatingLabel={true}
autoComplete="new-password"
placeholder="New password"
fullWidth
{...register('newPassword', {
required: 'Password is required',
pattern: {
value:
/^(?=.*[a-zA-Z])(?=.*[0-9])(?=.*[~`!?@#$%^&*()\-_+={}[\]|\\;:'"<>,./])(?:[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) || ''}
/>
<InputTextBox
type="password"
label="Confirm new password"
blindLabel={true}
floatingLabel={true}
autoComplete="new-password"
placeholder="Confirm new password"
fullWidth
{...register('confirmPassword', {
required: 'Confirm password is required',
validate: (value) => value === watch('newPassword') || 'Passwords do not match',
onChange: async () => {
await trigger('confirmPassword');
},
})}
state={formErrors.confirmPassword ? 'error' : 'normal'}
helperText={(formErrors.confirmPassword && formErrors.confirmPassword.message) || ''}
/>
</Modal.Content>
<Modal.Bottom>
<Button.Box fullWidth>
<Button outline onClick={onClose}>
Cancel
</Button>
<Button type="submit" disabled={status === 'loading' || isSuccess} color="info">
Change Password
</Button>
</Button.Box>
</Modal.Bottom>
<Modal.CloseButton onClick={onClose} />
</form>
</Modal>
)}
</div>
);
}
66 changes: 66 additions & 0 deletions src/features/users/usersSlice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -75,6 +83,13 @@ export type SignupFields = {
confirmPassword: string;
};

export type ChangePasswordFields = {
username: string;
password: string;
newPassword: string;
confirmPassword: string;
};

type JWTPayload = {
username: string;
};
Expand All @@ -96,6 +111,11 @@ const initialState: UsersState = {
status: 'idle',
error: null,
},
changePassword: {
isSuccess: false,
status: 'idle',
error: null,
},
signup: {
isSuccess: false,
status: 'idle',
Expand Down Expand Up @@ -137,6 +157,13 @@ export const deleteUser = createAppThunk<void, LoginFields>('users/deleteAccount
return await api.deleteAccount(username, password);
});

export const changePassword = createAppThunk<void, ChangePasswordFields>(
'users/changePassword',
async ({ username, password, newPassword }) => {
return await api.ChangePassword(username, password, newPassword);
},
);

export const usersSlice = createSlice({
name: 'users',
initialState,
Expand Down Expand Up @@ -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';
});
Expand Down
3 changes: 3 additions & 0 deletions src/pages/SettingsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -35,11 +36,13 @@ export function SettingsPage() {
<div className="setting_group">
<Navigator
navList={[
{ name: 'Account', id: 'account' },
{ name: 'Preferences', id: 'preferences' },
{ name: 'Danger zone', id: 'danger' },
]}
/>
<div className="box_right">
<Account />
<Preferences />
<DangerZone />
</div>
Expand Down

0 comments on commit 80cfea3

Please sign in to comment.