feat: add profile page and change password form
This commit is contained in:
parent
0c1c72ccab
commit
461d819246
@ -9,3 +9,7 @@ export function getJWT(): string | null {
|
|||||||
export function setJWT(jwt: string) {
|
export function setJWT(jwt: string) {
|
||||||
window.localStorage.setItem(TOKEN_STORAGE_KEY, jwt);
|
window.localStorage.setItem(TOKEN_STORAGE_KEY, jwt);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function clearJWT() {
|
||||||
|
window.localStorage.removeItem(TOKEN_STORAGE_KEY);
|
||||||
|
}
|
||||||
|
@ -1,4 +1,8 @@
|
|||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
|
import { useUserStore } from '../../store/user';
|
||||||
|
import { useEvent } from '../../hooks/useEvent';
|
||||||
|
import { useNavigate } from 'react-router';
|
||||||
|
import { clearJWT } from '../auth';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mock
|
* Mock
|
||||||
@ -7,3 +11,15 @@ import dayjs from 'dayjs';
|
|||||||
export function getUserTimezone(): string {
|
export function getUserTimezone(): string {
|
||||||
return dayjs.tz.guess() ?? 'utc';
|
return dayjs.tz.guess() ?? 'utc';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useLogout() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const logout = useEvent(() => {
|
||||||
|
useUserStore.setState({ info: null });
|
||||||
|
clearJWT();
|
||||||
|
navigate('/login');
|
||||||
|
});
|
||||||
|
|
||||||
|
return logout;
|
||||||
|
}
|
||||||
|
@ -4,6 +4,7 @@ import { NavItem } from '../components/NavItem';
|
|||||||
import { UserOutlined } from '@ant-design/icons';
|
import { UserOutlined } from '@ant-design/icons';
|
||||||
import { Button, Dropdown } from 'antd';
|
import { Button, Dropdown } from 'antd';
|
||||||
import { useUserStore } from '../store/user';
|
import { useUserStore } from '../store/user';
|
||||||
|
import { useLogout } from '../api/model/user';
|
||||||
|
|
||||||
export const Layout: React.FC = React.memo(() => {
|
export const Layout: React.FC = React.memo(() => {
|
||||||
const [params] = useSearchParams();
|
const [params] = useSearchParams();
|
||||||
@ -14,12 +15,13 @@ export const Layout: React.FC = React.memo(() => {
|
|||||||
id: w.workspace.id,
|
id: w.workspace.id,
|
||||||
name: w.workspace.name,
|
name: w.workspace.name,
|
||||||
role: w.role,
|
role: w.role,
|
||||||
current: userInfo.currentWorkspace.id === w.workspace.id,
|
current: userInfo.currentWorkspace?.id === w.workspace.id,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
return [];
|
return [];
|
||||||
});
|
});
|
||||||
|
const logout = useLogout();
|
||||||
const showHeader = !params.has('hideHeader');
|
const showHeader = !params.has('hideHeader');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -57,6 +59,9 @@ export const Layout: React.FC = React.memo(() => {
|
|||||||
{
|
{
|
||||||
key: 'logout',
|
key: 'logout',
|
||||||
label: 'Logout',
|
label: 'Logout',
|
||||||
|
onClick: () => {
|
||||||
|
logout();
|
||||||
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}}
|
}}
|
||||||
|
111
src/client/pages/Settings/Profile.tsx
Normal file
111
src/client/pages/Settings/Profile.tsx
Normal file
@ -0,0 +1,111 @@
|
|||||||
|
import { Button, Card, Form, Input, Modal, Typography } from 'antd';
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useUserStore } from '../../store/user';
|
||||||
|
import { PageHeader } from '../../components/PageHeader';
|
||||||
|
import {
|
||||||
|
defaultErrorHandler,
|
||||||
|
defaultSuccessHandler,
|
||||||
|
trpc,
|
||||||
|
} from '../../api/trpc';
|
||||||
|
import { useLogout } from '../../api/model/user';
|
||||||
|
|
||||||
|
export const Profile: React.FC = React.memo(() => {
|
||||||
|
const userInfo = useUserStore((state) => state.info);
|
||||||
|
const [openChangePassword, setOpenChangePassword] = useState(false);
|
||||||
|
|
||||||
|
const changePasswordMutation = trpc.user.changePassword.useMutation({
|
||||||
|
onSuccess: defaultSuccessHandler,
|
||||||
|
onError: defaultErrorHandler,
|
||||||
|
});
|
||||||
|
|
||||||
|
const logout = useLogout();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<PageHeader title="Profile" />
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<Form layout="vertical">
|
||||||
|
<Form.Item label="User Id">
|
||||||
|
<Typography.Text copyable={true} code={true}>
|
||||||
|
{userInfo?.id}
|
||||||
|
</Typography.Text>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="Current Workspace Id">
|
||||||
|
<Typography.Text copyable={true} code={true}>
|
||||||
|
{userInfo?.currentWorkspace?.id}
|
||||||
|
</Typography.Text>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="Password">
|
||||||
|
<Button danger={true} onClick={() => setOpenChangePassword(true)}>
|
||||||
|
Change Password
|
||||||
|
</Button>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
open={openChangePassword}
|
||||||
|
footer={null}
|
||||||
|
maskClosable={false}
|
||||||
|
onCancel={() => setOpenChangePassword(false)}
|
||||||
|
destroyOnClose={true}
|
||||||
|
>
|
||||||
|
<Form
|
||||||
|
layout="vertical"
|
||||||
|
onFinish={async (values) => {
|
||||||
|
const { oldPassword, newPassword } = values;
|
||||||
|
await changePasswordMutation.mutateAsync({
|
||||||
|
oldPassword,
|
||||||
|
newPassword,
|
||||||
|
});
|
||||||
|
logout();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Form.Item
|
||||||
|
label="Old Password"
|
||||||
|
name="oldPassword"
|
||||||
|
rules={[{ required: true }]}
|
||||||
|
>
|
||||||
|
<Input.Password />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
label="New Password"
|
||||||
|
name="newPassword"
|
||||||
|
rules={[{ required: true }]}
|
||||||
|
>
|
||||||
|
<Input.Password />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
label="Old Password"
|
||||||
|
name="newPasswordRepeat"
|
||||||
|
rules={[
|
||||||
|
{ required: true },
|
||||||
|
(form) => ({
|
||||||
|
validator(rule, value) {
|
||||||
|
if (!value || form.getFieldValue('newPassword') === value) {
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.reject('The two passwords are not consistent');
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Input.Password />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item className="text-right">
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
htmlType="submit"
|
||||||
|
loading={changePasswordMutation.isLoading}
|
||||||
|
>
|
||||||
|
Submit
|
||||||
|
</Button>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
Profile.displayName = 'Profile';
|
@ -5,22 +5,27 @@ import { WebsiteInfo } from '../../components/WebsiteInfo';
|
|||||||
import { WebsiteList } from '../../components/WebsiteList';
|
import { WebsiteList } from '../../components/WebsiteList';
|
||||||
import { useEvent } from '../../hooks/useEvent';
|
import { useEvent } from '../../hooks/useEvent';
|
||||||
import { NotificationList } from './NotificationList';
|
import { NotificationList } from './NotificationList';
|
||||||
|
import { Profile } from './Profile';
|
||||||
|
|
||||||
|
const items: MenuProps['items'] = [
|
||||||
|
{
|
||||||
|
key: 'websites',
|
||||||
|
label: 'Websites',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'notifications',
|
||||||
|
label: 'Notifications',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'profile',
|
||||||
|
label: 'Profile',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
export const SettingsPage: React.FC = React.memo(() => {
|
export const SettingsPage: React.FC = React.memo(() => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { pathname } = useLocation();
|
const { pathname } = useLocation();
|
||||||
|
|
||||||
const items: MenuProps['items'] = [
|
|
||||||
{
|
|
||||||
key: 'websites',
|
|
||||||
label: 'Websites',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'notifications',
|
|
||||||
label: 'Notifications',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const onClick: MenuProps['onClick'] = useEvent((e) => {
|
const onClick: MenuProps['onClick'] = useEvent((e) => {
|
||||||
navigate(`/settings/${e.key}`);
|
navigate(`/settings/${e.key}`);
|
||||||
});
|
});
|
||||||
@ -46,6 +51,7 @@ export const SettingsPage: React.FC = React.memo(() => {
|
|||||||
<Route path="/websites" element={<WebsiteList />} />
|
<Route path="/websites" element={<WebsiteList />} />
|
||||||
<Route path="/website/:websiteId" element={<WebsiteInfo />} />
|
<Route path="/website/:websiteId" element={<WebsiteInfo />} />
|
||||||
<Route path="/notifications" element={<NotificationList />} />
|
<Route path="/notifications" element={<NotificationList />} />
|
||||||
|
<Route path="/profile" element={<Profile />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -2,6 +2,7 @@ import { prisma } from './_client';
|
|||||||
import bcryptjs from 'bcryptjs';
|
import bcryptjs from 'bcryptjs';
|
||||||
import { ROLES, SYSTEM_ROLES } from '../utils/const';
|
import { ROLES, SYSTEM_ROLES } from '../utils/const';
|
||||||
import { jwtVerify } from '../middleware/auth';
|
import { jwtVerify } from '../middleware/auth';
|
||||||
|
import { TRPCError } from '@trpc/server';
|
||||||
|
|
||||||
async function hashPassword(password: string) {
|
async function hashPassword(password: string) {
|
||||||
return await bcryptjs.hash(password, 10);
|
return await bcryptjs.hash(password, 10);
|
||||||
@ -191,3 +192,39 @@ export async function findUser(userId: string) {
|
|||||||
|
|
||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function changeUserPassword(
|
||||||
|
userId: string,
|
||||||
|
oldPassword: string,
|
||||||
|
newPassword: string
|
||||||
|
) {
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: {
|
||||||
|
id: userId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'INTERNAL_SERVER_ERROR',
|
||||||
|
message: 'user not found',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const checkPassword = await comparePassword(oldPassword, user.password);
|
||||||
|
if (!checkPassword) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'INTERNAL_SERVER_ERROR',
|
||||||
|
message: 'old password not correct',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return prisma.user.update({
|
||||||
|
where: {
|
||||||
|
id: userId,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
password: await hashPassword(newPassword),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
@ -1,8 +1,9 @@
|
|||||||
import { publicProcedure, router } from '../trpc';
|
import { protectProedure, publicProcedure, router } from '../trpc';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import {
|
import {
|
||||||
authUser,
|
authUser,
|
||||||
authUserWithToken,
|
authUserWithToken,
|
||||||
|
changeUserPassword,
|
||||||
createAdminUser,
|
createAdminUser,
|
||||||
createUser,
|
createUser,
|
||||||
getUserCount,
|
getUserCount,
|
||||||
@ -74,4 +75,17 @@ export const userRouter = router({
|
|||||||
return { info: user, token };
|
return { info: user, token };
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
changePassword: protectProedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
oldPassword: z.string(),
|
||||||
|
newPassword: z.string(),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.mutation(async ({ input, ctx }) => {
|
||||||
|
const userId = ctx.user.id;
|
||||||
|
const { oldPassword, newPassword } = input;
|
||||||
|
|
||||||
|
return changeUserPassword(userId, oldPassword, newPassword);
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
Loading…
Reference in New Issue
Block a user