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 (
Tianji -
+ { name="password" rules={[{ required: true }]} > - + - + + +
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 }); } );