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}
+ >
+
+
+
+
+
+
+ ({
+ 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);
+ }),
});