diff --git a/package.json b/package.json
index bb2b01e..0ef098a 100644
--- a/package.json
+++ b/package.json
@@ -47,7 +47,8 @@
"typescript": "^4.9.5",
"uuid": "^9.0.0",
"vite-express": "^0.10.0",
- "yup": "^1.2.0"
+ "yup": "^1.2.0",
+ "zustand": "^4.4.1"
},
"devDependencies": {
"@types/bcryptjs": "^2.4.3",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 19cc90d..e7cd97e 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -106,6 +106,9 @@ dependencies:
yup:
specifier: ^1.2.0
version: 1.2.0
+ zustand:
+ specifier: ^4.4.1
+ version: 4.4.1(@types/react@18.2.21)(react@18.2.0)
devDependencies:
'@types/bcryptjs':
@@ -1960,7 +1963,6 @@ packages:
/@types/prop-types@15.7.5:
resolution: {integrity: sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w==}
- dev: true
/@types/qs@6.9.8:
resolution: {integrity: sha512-u95svzDlTysU5xecFNTgfFG5RUWu1A9P0VzgpcIiGZA9iraHOdSzcxMxQ55DyeRaGCSxQi7LxXDI4rzq/MYfdg==}
@@ -1982,7 +1984,6 @@ packages:
'@types/prop-types': 15.7.5
'@types/scheduler': 0.16.3
csstype: 3.1.2
- dev: true
/@types/request-ip@0.0.38:
resolution: {integrity: sha512-1yeq8UuK/tUBqLXRY24gjeFvrSNaGNcOcZLQjHlnuw8iu+qE/vTQ64TUcLWorr607NKLfFakdoYEXXHXrLLKCw==}
@@ -1992,7 +1993,6 @@ packages:
/@types/scheduler@0.16.3:
resolution: {integrity: sha512-5cJ8CB4yAx7BH1oMvdU0Jh9lrEXyPkar6F9G/ERswkCuvP4KQZfZkSjcMbAICCpQTN4OuZn8tz0HiKv9TGZgrQ==}
- dev: true
/@types/send@0.17.1:
resolution: {integrity: sha512-Cwo8LE/0rnvX7kIIa3QHCkcuF21c05Ayb0ZfxPiv0W8VRiZiNW/WuRupHKpqqGVGf7SUA44QSOUKaEd9lIrd/Q==}
@@ -5856,6 +5856,14 @@ packages:
punycode: 2.3.0
dev: false
+ /use-sync-external-store@1.2.0(react@18.2.0):
+ resolution: {integrity: sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==}
+ peerDependencies:
+ react: ^16.8.0 || ^17.0.0 || ^18.0.0
+ dependencies:
+ react: 18.2.0
+ dev: false
+
/util-deprecate@1.0.2:
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
dev: true
@@ -6034,3 +6042,23 @@ packages:
toposort: 2.0.2
type-fest: 2.19.0
dev: false
+
+ /zustand@4.4.1(@types/react@18.2.21)(react@18.2.0):
+ resolution: {integrity: sha512-QCPfstAS4EBiTQzlaGP1gmorkh/UL1Leaj2tdj+zZCZ/9bm0WS7sI2wnfD5lpOszFqWJ1DcPnGoY8RDL61uokw==}
+ engines: {node: '>=12.7.0'}
+ peerDependencies:
+ '@types/react': '>=16.8'
+ immer: '>=9.0'
+ react: '>=16.8'
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ immer:
+ optional: true
+ react:
+ optional: true
+ dependencies:
+ '@types/react': 18.2.21
+ react: 18.2.0
+ use-sync-external-store: 1.2.0(react@18.2.0)
+ dev: false
diff --git a/src/client/App.tsx b/src/client/App.tsx
index c6ade44..84b27af 100644
--- a/src/client/App.tsx
+++ b/src/client/App.tsx
@@ -6,25 +6,34 @@ import { Monitor } from './pages/Monitor';
import { Website } from './pages/Website';
import { Settings } from './pages/Settings';
import { Servers } from './pages/Servers';
+import { useUserStore } from './store/user';
+import { Register } from './pages/Register';
function App() {
+ const { info } = useUserStore();
+
return (
- }>
- } />
- } />
- } />
- } />
- } />
-
+ {info && (
+ }>
+ } />
+ } />
+ } />
+ } />
+ } />
+
+ )}
} />
+ } />
}
+ element={
+
+ }
/>
diff --git a/src/client/api/auth.ts b/src/client/api/auth.ts
new file mode 100644
index 0000000..82085f3
--- /dev/null
+++ b/src/client/api/auth.ts
@@ -0,0 +1,11 @@
+const TOKEN_STORAGE_KEY = 'jsonwebtoken';
+
+export function getJWT(): string | null {
+ const token = window.localStorage.getItem(TOKEN_STORAGE_KEY);
+
+ return token ?? null;
+}
+
+export function setJWT(jwt: string) {
+ window.localStorage.setItem(TOKEN_STORAGE_KEY, jwt);
+}
diff --git a/src/client/api/model/index.ts b/src/client/api/model/index.ts
new file mode 100644
index 0000000..22c2de0
--- /dev/null
+++ b/src/client/api/model/index.ts
@@ -0,0 +1,5 @@
+import * as user from './user';
+
+export const model = {
+ user,
+};
diff --git a/src/client/api/model/user.ts b/src/client/api/model/user.ts
new file mode 100644
index 0000000..5e87278
--- /dev/null
+++ b/src/client/api/model/user.ts
@@ -0,0 +1,32 @@
+import { setUserInfo, UserLoginInfo } from '../../store/user';
+import { getJWT, setJWT } from '../auth';
+import { request } from '../request';
+
+export async function login(username: string, password: string) {
+ const { data } = await request.post('/api/user/login', {
+ username,
+ password,
+ });
+
+ setJWT(data.token);
+ setUserInfo(data.info as UserLoginInfo);
+}
+
+export async function loginWithToken() {
+ const { data } = await request.post('/api/user/loginWithToken', {
+ token: getJWT(),
+ });
+
+ setJWT(data.token);
+ setUserInfo(data.info as UserLoginInfo);
+}
+
+export async function register(username: string, password: string) {
+ const { data } = await request.post('/api/user/register', {
+ username,
+ password,
+ });
+
+ setJWT(data.token);
+ setUserInfo(data.info as UserLoginInfo);
+}
diff --git a/src/client/api/request.ts b/src/client/api/request.ts
new file mode 100644
index 0000000..38e58bf
--- /dev/null
+++ b/src/client/api/request.ts
@@ -0,0 +1,76 @@
+import { message } from 'antd';
+import axios from 'axios';
+import { get } from 'lodash-es';
+import { getJWT } from './auth';
+
+class RequestError extends Error {}
+
+function createRequest() {
+ const ins = axios.create();
+
+ ins.interceptors.request.use(async (val) => {
+ if (
+ ['post', 'get'].includes(String(val.method).toLowerCase()) &&
+ !val.headers.Authorization
+ ) {
+ val.headers.Authorization = `Bearer ${getJWT()}`;
+ }
+
+ return val;
+ });
+
+ ins.interceptors.response.use(
+ (val) => {
+ return val;
+ },
+ (err) => {
+ console.log(err);
+ const responseData = get(err, 'response.data') ?? {};
+ let errorMsg: string = responseData.message;
+ const code: number = responseData.code;
+
+ const statusCode = get(err, 'response.header.code');
+ if (
+ statusCode === 401 // Unauthorized (jwt expired)
+ ) {
+ backToLoginPage();
+
+ return { data: { result: false, msg: errorMsg, code } };
+ }
+
+ throw new RequestError(errorMsg ?? err.message);
+ }
+ );
+
+ return ins;
+}
+
+const backToLoginPage = (() => {
+ let timer: number;
+
+ return () => {
+ if (timer) {
+ // Skip if existed
+ return;
+ }
+
+ if (
+ window.location.pathname.startsWith('/login') ||
+ window.location.pathname.startsWith('/register')
+ ) {
+ // Skip login page
+ return;
+ }
+
+ message.warning(
+ 'The account authorization has expired. It will automatically jump to the login page in 2 seconds.'
+ );
+
+ timer = window.setTimeout(() => {
+ window.clearTimeout(timer);
+ window.location.href = '/login';
+ }, 2000);
+ };
+})();
+
+export const request = createRequest();
diff --git a/src/client/hooks/useRequest.ts b/src/client/hooks/useRequest.ts
new file mode 100644
index 0000000..ace52b8
--- /dev/null
+++ b/src/client/hooks/useRequest.ts
@@ -0,0 +1,28 @@
+import { message } from 'antd';
+import { useState } from 'react';
+import { useEvent } from './useEvent';
+
+export function useRequest
(queryFn: (...args: P[]) => Promise) {
+ const [loading, setLoading] = useState(false);
+ const [data, setData] = useState(undefined);
+
+ const run = useEvent(async (...args: P[]) => {
+ try {
+ setLoading(true);
+ const res = await queryFn(...args);
+ setData(res);
+ } catch (err: any) {
+ message.error(err.message ?? String(err));
+ } finally {
+ setLoading(false);
+ }
+ });
+
+ return [
+ {
+ loading,
+ data,
+ },
+ run,
+ ] as const;
+}
diff --git a/src/client/pages/Login.tsx b/src/client/pages/Login.tsx
index 4f1814d..39e2ed3 100644
--- a/src/client/pages/Login.tsx
+++ b/src/client/pages/Login.tsx
@@ -1,21 +1,33 @@
import { Button, Form, Input, Typography } from 'antd';
-import React from 'react';
-import { useEvent } from '../hooks/useEvent';
-import axios from 'axios';
+import React, { useEffect } from 'react';
+import { model } from '../api/model';
+import { useNavigate } from 'react-router';
+import { loginWithToken } from '../api/model/user';
+import { getJWT } from '../api/auth';
+import { useRequest } from '../hooks/useRequest';
export const Login: React.FC = React.memo(() => {
- const handleLogin = useEvent(async (values: any) => {
- await axios.post('/api/user/login', {
- username: values.username,
- password: values.password,
- });
+ const navigate = useNavigate();
+
+ const [{ loading }, handleLogin] = useRequest(async (values: any) => {
+ await model.user.login(values.username, values.password);
+ navigate('/dashboard');
});
+ useEffect(() => {
+ const token = getJWT();
+ if (token) {
+ loginWithToken().then(() => {
+ navigate('/dashboard');
+ });
+ }
+ }, []);
+
return (
diff --git a/src/client/pages/Register.tsx b/src/client/pages/Register.tsx
new file mode 100644
index 0000000..22e83de
--- /dev/null
+++ b/src/client/pages/Register.tsx
@@ -0,0 +1,50 @@
+import { Button, Form, Input, Typography } from 'antd';
+import React from 'react';
+import { model } from '../api/model';
+import { useNavigate } from 'react-router';
+import { useRequest } from '../hooks/useRequest';
+
+export const Register: React.FC = React.memo(() => {
+ const navigate = useNavigate();
+
+ const [{ loading }, handleRegister] = useRequest(async (values: any) => {
+ await model.user.register(values.username, values.password);
+ navigate('/dashboard');
+ });
+
+ return (
+
+
+ Register Account
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+});
+Register.displayName = 'Register';
diff --git a/src/client/store/user.ts b/src/client/store/user.ts
new file mode 100644
index 0000000..89bfe87
--- /dev/null
+++ b/src/client/store/user.ts
@@ -0,0 +1,32 @@
+import { create } from 'zustand';
+
+export interface UserLoginInfo {
+ id: string;
+ username: string;
+ role: string;
+ currentWorkspace: {
+ id: string;
+ name: string;
+ };
+ workspaces: {
+ role: string;
+ workspace: {
+ id: string;
+ name: string;
+ };
+ }[];
+}
+
+interface UserState {
+ info: UserLoginInfo | null;
+}
+
+export const useUserStore = create(() => ({
+ info: null,
+}));
+
+export function setUserInfo(info: UserInfo) {
+ useUserStore.setState({
+ info,
+ });
+}
diff --git a/src/server/main.ts b/src/server/main.ts
index df237e0..911d3ab 100644
--- a/src/server/main.ts
+++ b/src/server/main.ts
@@ -22,8 +22,8 @@ app.use('/api/user', userRouter);
app.use('/api/website', websiteRouter);
app.use((err: any, req: any, res: any, next: any) => {
- res.status(500);
- res.json({ error: err.message });
+ console.error(err);
+ res.status(500).json({ message: err.message });
});
ViteExpress.listen(app, port, () => {
diff --git a/src/server/middleware/auth.ts b/src/server/middleware/auth.ts
index db7b1d4..d8abeaf 100644
--- a/src/server/middleware/auth.ts
+++ b/src/server/middleware/auth.ts
@@ -1,4 +1,4 @@
-import { authUser, findUser } from '../model/user';
+import { findUser } from '../model/user';
import passport from 'passport';
import { Handler } from 'express';
import { Strategy as JwtStrategy, ExtractJwt } from 'passport-jwt';
@@ -9,6 +9,12 @@ export const jwtSecret = process.env.JWT_SECRET || nanoid();
export const jwtIssuer = process.env.JWT_ISSUER || 'tianji.msgbyte.com';
export const jwtAudience = process.env.JWT_AUDIENCE || 'msgbyte.com';
+interface JWTPayload {
+ id: string;
+ username: string;
+ role: string;
+}
+
passport.use(
new JwtStrategy(
{
@@ -18,7 +24,7 @@ passport.use(
audience: jwtAudience,
},
function (jwt_payload, done) {
- findUser(jwt_payload.sub)
+ findUser(jwt_payload.id)
.then((user) => {
if (user) {
done(null, user);
@@ -41,16 +47,33 @@ passport.deserializeUser(function (user: any, cb) {
cb(null, user);
});
-export function jwtSign(payload: {}): string {
- const token = jwt.sign(payload, jwtSecret, {
- issuer: jwtIssuer,
- audience: jwtAudience,
- expiresIn: '30d',
- });
+export function jwtSign(payload: JWTPayload): string {
+ const token = jwt.sign(
+ {
+ id: payload.id,
+ username: payload.username,
+ role: payload.role,
+ },
+ jwtSecret,
+ {
+ issuer: jwtIssuer,
+ audience: jwtAudience,
+ expiresIn: '30d',
+ }
+ );
return token;
}
+export function jwtVerify(token: string): JWTPayload {
+ const payload = jwt.verify(token, jwtSecret, {
+ issuer: jwtIssuer,
+ audience: jwtAudience,
+ });
+
+ return payload as JWTPayload;
+}
+
export function auth(): Handler {
return passport.authenticate('jwt', {
session: false,
diff --git a/src/server/middleware/validate.ts b/src/server/middleware/validate.ts
index c117beb..087762d 100644
--- a/src/server/middleware/validate.ts
+++ b/src/server/middleware/validate.ts
@@ -18,4 +18,4 @@ export function validate(...validator: ValidationChain[]): Handler {
return compose([...validator, handler as any]);
}
-export { body, query, param } from 'express-validator';
+export { body, query, param, header } from 'express-validator';
diff --git a/src/server/model/user.ts b/src/server/model/user.ts
index bbefbfe..23e192c 100644
--- a/src/server/model/user.ts
+++ b/src/server/model/user.ts
@@ -1,11 +1,48 @@
import { prisma } from './_client';
import bcryptjs from 'bcryptjs';
import { ROLES } from '../utils/const';
+import { jwtVerify } from '../middleware/auth';
async function hashPassword(password: string) {
return await bcryptjs.hash(password, 10);
}
+function comparePassword(password: string, hash: string): Promise {
+ return bcryptjs.compare(password, hash);
+}
+
+export async function getUserCount(): Promise {
+ const count = await prisma.user.count();
+
+ return count;
+}
+
+const createUserSelect = {
+ id: true,
+ username: true,
+ role: true,
+ createdAt: true,
+ updatedAt: true,
+ deletedAt: true,
+ currentWorkspace: {
+ select: {
+ id: true,
+ name: true,
+ },
+ },
+ workspaces: {
+ select: {
+ role: true,
+ workspace: {
+ select: {
+ id: true,
+ name: true,
+ },
+ },
+ },
+ },
+};
+
/**
* Create User
*/
@@ -13,7 +50,9 @@ export async function createAdminUser(username: string, password: string) {
const count = await prisma.user.count();
if (count > 0) {
- throw new Error('Create Admin User Just Only allow in non people exist');
+ throw new Error(
+ 'Create Admin User Just Only allow in non people exist, you can Grant Privilege with admin user'
+ );
}
const user = await prisma.user.create({
@@ -34,9 +73,7 @@ export async function createAdminUser(username: string, password: string) {
],
},
},
- include: {
- workspaces: true,
- },
+ select: createUserSelect,
});
if (user.workspaces[0]) {
@@ -45,10 +82,12 @@ export async function createAdminUser(username: string, password: string) {
id: user.id,
},
data: {
- currentWorkspaceId: user.workspaces[0].workspaceId,
+ currentWorkspaceId: user.workspaces[0].workspace.id,
},
});
}
+
+ return user;
}
export async function createUser(username: string, password: string) {
@@ -80,9 +119,7 @@ export async function createUser(username: string, password: string) {
],
},
},
- include: {
- workspaces: true,
- },
+ select: createUserSelect,
});
if (user.workspaces[0]) {
@@ -91,26 +128,46 @@ export async function createUser(username: string, password: string) {
id: user.id,
},
data: {
- currentWorkspaceId: user.workspaces[0].workspaceId,
+ currentWorkspaceId: user.workspaces[0].workspace.id,
},
});
}
+
+ return user;
}
export async function authUser(username: string, password: string) {
- const user = await prisma.user.findUniqueOrThrow({
+ const user = await prisma.user.findUnique({
where: {
username,
- password: await hashPassword(password),
},
- select: {
- id: true,
- username: true,
- role: true,
- createdAt: true,
- updatedAt: true,
- deletedAt: true,
+ select: { ...createUserSelect, password: true },
+ });
+
+ if (!user) {
+ throw new Error('User not existed');
+ }
+
+ const checkPassword = await comparePassword(password, user.password);
+ if (!checkPassword) {
+ throw new Error('Password incorrected');
+ }
+
+ delete (user as any)['password'];
+
+ return user;
+}
+
+export async function authUserWithToken(token: string) {
+ const payload = jwtVerify(token);
+
+ const id = payload.id;
+
+ const user = await prisma.user.findUniqueOrThrow({
+ where: {
+ id,
},
+ select: createUserSelect,
});
return user;
diff --git a/src/server/router/user.ts b/src/server/router/user.ts
index 6596c1e..f4cc79b 100644
--- a/src/server/router/user.ts
+++ b/src/server/router/user.ts
@@ -1,6 +1,12 @@
import { Router } from 'express';
-import { body, validate } from '../middleware/validate';
-import { authUser, createAdminUser } from '../model/user';
+import { header, body, validate } from '../middleware/validate';
+import {
+ authUser,
+ authUserWithToken,
+ createAdminUser,
+ createUser,
+ getUserCount,
+} from '../model/user';
import { auth, jwtSign } from '../middleware/auth';
export const userRouter = Router();
@@ -16,13 +22,53 @@ userRouter.post(
const user = await authUser(username, password);
- const token = jwtSign({
- id: user.id,
- username: user.username,
- role: user.role,
- });
+ const token = jwtSign(user);
- res.json({ token });
+ res.json({ info: user, token });
+ }
+);
+
+userRouter.post(
+ '/register',
+ validate(
+ body('username').exists().withMessage('Username should be existed'),
+ body('password').exists().withMessage('Password should be existed')
+ ),
+ async (req, res) => {
+ const { username, password } = req.body;
+
+ const userCount = await getUserCount();
+ if (userCount === 0) {
+ const user = await createAdminUser(username, password);
+
+ const token = jwtSign(user);
+
+ res.json({ info: user, token });
+ } else {
+ const user = await createUser(username, password);
+
+ const token = jwtSign(user);
+
+ res.json({ info: user, token });
+ }
+ }
+);
+
+userRouter.post(
+ '/loginWithToken',
+ validate(body('token').exists().withMessage('Token should be existed')),
+ async (req, res) => {
+ const { token } = req.body;
+
+ if (!token) {
+ throw new Error('Cannot get token');
+ }
+
+ const user = await authUserWithToken(token);
+
+ const newToken = jwtSign(user);
+
+ res.json({ info: user, token: newToken });
}
);