diff --git a/src/client/api/auth.ts b/src/client/api/auth.ts index 82085f3..85bdd00 100644 --- a/src/client/api/auth.ts +++ b/src/client/api/auth.ts @@ -9,3 +9,7 @@ export function getJWT(): string | null { export function setJWT(jwt: string) { window.localStorage.setItem(TOKEN_STORAGE_KEY, jwt); } + +export function clearJWT() { + window.localStorage.removeItem(TOKEN_STORAGE_KEY); +} diff --git a/src/client/api/model/user.ts b/src/client/api/model/user.ts index 485456d..a8573f3 100644 --- a/src/client/api/model/user.ts +++ b/src/client/api/model/user.ts @@ -1,4 +1,8 @@ import dayjs from 'dayjs'; +import { useUserStore } from '../../store/user'; +import { useEvent } from '../../hooks/useEvent'; +import { useNavigate } from 'react-router'; +import { clearJWT } from '../auth'; /** * Mock @@ -7,3 +11,15 @@ import dayjs from 'dayjs'; export function getUserTimezone(): string { return dayjs.tz.guess() ?? 'utc'; } + +export function useLogout() { + const navigate = useNavigate(); + + const logout = useEvent(() => { + useUserStore.setState({ info: null }); + clearJWT(); + navigate('/login'); + }); + + return logout; +} diff --git a/src/client/pages/Layout.tsx b/src/client/pages/Layout.tsx index efd69da..7cee6ad 100644 --- a/src/client/pages/Layout.tsx +++ b/src/client/pages/Layout.tsx @@ -4,6 +4,7 @@ import { NavItem } from '../components/NavItem'; import { UserOutlined } from '@ant-design/icons'; import { Button, Dropdown } from 'antd'; import { useUserStore } from '../store/user'; +import { useLogout } from '../api/model/user'; export const Layout: React.FC = React.memo(() => { const [params] = useSearchParams(); @@ -14,12 +15,13 @@ export const Layout: React.FC = React.memo(() => { id: w.workspace.id, name: w.workspace.name, role: w.role, - current: userInfo.currentWorkspace.id === w.workspace.id, + current: userInfo.currentWorkspace?.id === w.workspace.id, })); } return []; }); + const logout = useLogout(); const showHeader = !params.has('hideHeader'); return ( @@ -57,6 +59,9 @@ export const Layout: React.FC = React.memo(() => { { key: 'logout', label: 'Logout', + onClick: () => { + logout(); + }, }, ], }} diff --git a/src/client/pages/Settings/Profile.tsx b/src/client/pages/Settings/Profile.tsx new file mode 100644 index 0000000..de7d097 --- /dev/null +++ b/src/client/pages/Settings/Profile.tsx @@ -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 ( +
+ + + +
+ + + {userInfo?.id} + + + + + {userInfo?.currentWorkspace?.id} + + + + + +
+
+ + setOpenChangePassword(false)} + destroyOnClose={true} + > +
{ + const { oldPassword, newPassword } = values; + await changePasswordMutation.mutateAsync({ + oldPassword, + newPassword, + }); + logout(); + }} + > + + + + + + + ({ + validator(rule, value) { + if (!value || form.getFieldValue('newPassword') === value) { + return Promise.resolve(); + } + + return Promise.reject('The two passwords are not consistent'); + }, + }), + ]} + > + + + + + +
+
+
+ ); +}); +Profile.displayName = 'Profile'; diff --git a/src/client/pages/Settings/index.tsx b/src/client/pages/Settings/index.tsx index 81d235f..04d04b1 100644 --- a/src/client/pages/Settings/index.tsx +++ b/src/client/pages/Settings/index.tsx @@ -5,22 +5,27 @@ import { WebsiteInfo } from '../../components/WebsiteInfo'; import { WebsiteList } from '../../components/WebsiteList'; import { useEvent } from '../../hooks/useEvent'; 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(() => { const navigate = useNavigate(); const { pathname } = useLocation(); - const items: MenuProps['items'] = [ - { - key: 'websites', - label: 'Websites', - }, - { - key: 'notifications', - label: 'Notifications', - }, - ]; - const onClick: MenuProps['onClick'] = useEvent((e) => { navigate(`/settings/${e.key}`); }); @@ -46,6 +51,7 @@ export const SettingsPage: React.FC = React.memo(() => { } /> } /> } /> + } /> diff --git a/src/server/model/user.ts b/src/server/model/user.ts index 5f1e3a5..e0d3561 100644 --- a/src/server/model/user.ts +++ b/src/server/model/user.ts @@ -2,6 +2,7 @@ import { prisma } from './_client'; import bcryptjs from 'bcryptjs'; import { ROLES, SYSTEM_ROLES } from '../utils/const'; import { jwtVerify } from '../middleware/auth'; +import { TRPCError } from '@trpc/server'; async function hashPassword(password: string) { return await bcryptjs.hash(password, 10); @@ -191,3 +192,39 @@ export async function findUser(userId: string) { 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), + }, + }); +} diff --git a/src/server/trpc/routers/user.ts b/src/server/trpc/routers/user.ts index 573126d..42ace88 100644 --- a/src/server/trpc/routers/user.ts +++ b/src/server/trpc/routers/user.ts @@ -1,8 +1,9 @@ -import { publicProcedure, router } from '../trpc'; +import { protectProedure, publicProcedure, router } from '../trpc'; import { z } from 'zod'; import { authUser, authUserWithToken, + changeUserPassword, createAdminUser, createUser, getUserCount, @@ -74,4 +75,17 @@ export const userRouter = router({ 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); + }), });