From 5db3506cbaea6dab5c6065b49d938654dad6f2a2 Mon Sep 17 00:00:00 2001 From: Steven Date: Sun, 9 Jul 2023 00:45:26 +0800 Subject: [PATCH] feat: add member list in setting --- api/v1/user.go | 82 ++++++- web/src/App.tsx | 38 +++- web/src/components/ChangePasswordDialog.tsx | 8 +- web/src/components/CreateUserDialog.tsx | 201 ++++++++++++++++++ web/src/components/EditUserinfoDialog.tsx | 17 +- web/src/components/Header.tsx | 6 +- web/src/components/ShortcutView.tsx | 6 +- web/src/components/setting/AccountSection.tsx | 12 +- web/src/components/setting/UserSection.tsx | 95 +++++++++ .../components/setting/WorkspaceSection.tsx | 3 +- web/src/helpers/api.ts | 4 + web/src/layouts/Root.tsx | 27 ++- web/src/main.tsx | 11 +- web/src/pages/Account.tsx | 8 +- web/src/pages/Home.tsx | 6 +- web/src/pages/Setting.tsx | 14 +- web/src/pages/SignIn.tsx | 7 +- web/src/pages/SignUp.tsx | 19 +- web/src/routers/index.tsx | 88 ++------ web/src/services/globalService.ts | 7 - web/src/services/index.ts | 3 +- web/src/services/userService.ts | 66 ------ web/src/stores/index.ts | 2 - web/src/stores/modules/user.ts | 31 --- web/src/stores/v1/user.ts | 83 ++++++++ web/src/types/modules/user.d.ts | 8 + 26 files changed, 614 insertions(+), 238 deletions(-) create mode 100644 web/src/components/CreateUserDialog.tsx create mode 100644 web/src/components/setting/UserSection.tsx delete mode 100644 web/src/services/userService.ts delete mode 100644 web/src/stores/modules/user.ts create mode 100644 web/src/stores/v1/user.ts diff --git a/api/v1/user.go b/api/v1/user.go index 4530aab..1c3774a 100644 --- a/api/v1/user.go +++ b/api/v1/user.go @@ -56,7 +56,7 @@ type CreateUserRequest struct { Email string `json:"email"` Nickname string `json:"nickname"` Password string `json:"password"` - Role Role `json:"-"` + Role Role `json:"role"` } func (create CreateUserRequest) Validate() error { @@ -78,9 +78,56 @@ type PatchUserRequest struct { Email *string `json:"email"` Nickname *string `json:"nickname"` Password *string `json:"password"` + Role *Role `json:"role"` } func (s *APIV1Service) registerUserRoutes(g *echo.Group) { + g.POST("/user", func(c echo.Context) error { + ctx := c.Request().Context() + userID, ok := c.Get(getUserIDContextKey()).(int) + if !ok { + return echo.NewHTTPError(http.StatusUnauthorized, "Missing auth session") + } + currentUser, err := s.Store.GetUser(ctx, &store.FindUser{ + ID: &userID, + }) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find user by id").SetInternal(err) + } + if currentUser == nil { + return echo.NewHTTPError(http.StatusUnauthorized, "Missing auth session") + } + if currentUser.Role != store.RoleAdmin { + return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized to create user") + } + + userCreate := &CreateUserRequest{} + if err := json.NewDecoder(c.Request().Body).Decode(userCreate); err != nil { + return echo.NewHTTPError(http.StatusBadRequest, "Malformatted post user request").SetInternal(err) + } + if err := userCreate.Validate(); err != nil { + return echo.NewHTTPError(http.StatusBadRequest, "Invalid user create format").SetInternal(err) + } + + passwordHash, err := bcrypt.GenerateFromPassword([]byte(userCreate.Password), bcrypt.DefaultCost) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to generate password hash").SetInternal(err) + } + + user, err := s.Store.CreateUser(ctx, &store.User{ + Role: store.Role(userCreate.Role), + Email: userCreate.Email, + Nickname: userCreate.Nickname, + PasswordHash: string(passwordHash), + }) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create user").SetInternal(err) + } + + userMessage := convertUserFromStore(user) + return c.JSON(http.StatusOK, userMessage) + }) + g.GET("/user", func(c echo.Context) error { ctx := c.Request().Context() list, err := s.Store.ListUsers(ctx, &store.FindUser{}) @@ -140,7 +187,16 @@ func (s *APIV1Service) registerUserRoutes(g *echo.Group) { if !ok { return echo.NewHTTPError(http.StatusUnauthorized, "missing user in session") } - if currentUserID != userID { + currentUser, err := s.Store.GetUser(ctx, &store.FindUser{ + ID: ¤tUserID, + }) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "failed to find current user").SetInternal(err) + } + if currentUser == nil { + return echo.NewHTTPError(http.StatusUnauthorized, "missing user in session") + } + if currentUser.ID != userID && currentUser.Role != store.RoleAdmin { return echo.NewHTTPError(http.StatusForbidden, "access forbidden for current session user").SetInternal(err) } @@ -150,7 +206,7 @@ func (s *APIV1Service) registerUserRoutes(g *echo.Group) { } updateUser := &store.UpdateUser{ - ID: currentUserID, + ID: userID, } if userPatch.Email != nil { if !validateEmail(*userPatch.Email) { @@ -170,6 +226,14 @@ func (s *APIV1Service) registerUserRoutes(g *echo.Group) { passwordHashStr := string(passwordHash) updateUser.PasswordHash = &passwordHashStr } + if userPatch.RowStatus != nil { + rowStatus := store.RowStatus(*userPatch.RowStatus) + updateUser.RowStatus = &rowStatus + } + if userPatch.Role != nil { + role := store.Role(*userPatch.Role) + updateUser.Role = &role + } user, err := s.Store.UpdateUser(ctx, updateUser) if err != nil { @@ -202,6 +266,18 @@ func (s *APIV1Service) registerUserRoutes(g *echo.Group) { if err != nil { return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("user id is not a number: %s", c.Param("id"))).SetInternal(err) } + user, err := s.Store.GetUser(ctx, &store.FindUser{ + ID: &userID, + }) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to find user, err: %s", err)).SetInternal(err) + } + if user == nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("user not found with ID: %d", userID)).SetInternal(err) + } + if user.Role == store.RoleAdmin { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("cannot delete admin user with ID: %d", userID)).SetInternal(err) + } if err := s.Store.DeleteUser(ctx, &store.DeleteUser{ ID: userID, diff --git a/web/src/App.tsx b/web/src/App.tsx index 9401f55..ac13a58 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,15 +1,33 @@ -import { CssVarsProvider } from "@mui/joy/styles"; -import { Toaster } from "react-hot-toast"; -import { RouterProvider } from "react-router-dom"; -import router from "./routers"; +import { useEffect, useState } from "react"; +import { Outlet } from "react-router-dom"; +import { globalService } from "./services"; +import useUserStore from "./stores/v1/user"; function App() { - return ( - - - - - ); + const userStore = useUserStore(); + const [loading, setLoading] = useState(true); + + useEffect(() => { + const initialState = async () => { + try { + await globalService.initialState(); + } catch (error) { + // do nothing + } + + try { + await userStore.fetchCurrentUser(); + } catch (error) { + // do nothing. + } + + setLoading(false); + }; + + initialState(); + }, []); + + return <>{!loading && }; } export default App; diff --git a/web/src/components/ChangePasswordDialog.tsx b/web/src/components/ChangePasswordDialog.tsx index fcf34fd..503ee69 100644 --- a/web/src/components/ChangePasswordDialog.tsx +++ b/web/src/components/ChangePasswordDialog.tsx @@ -2,7 +2,7 @@ import { Button, Input, Modal, ModalDialog } from "@mui/joy"; import { useState } from "react"; import { toast } from "react-hot-toast"; import useLoading from "../hooks/useLoading"; -import { userService } from "../services"; +import useUserStore from "../stores/v1/user"; import Icon from "./Icon"; interface Props { @@ -11,6 +11,7 @@ interface Props { const ChangePasswordDialog: React.FC = (props: Props) => { const { onClose } = props; + const userStore = useUserStore(); const [newPassword, setNewPassword] = useState(""); const [newPasswordAgain, setNewPasswordAgain] = useState(""); const requestState = useLoading(false); @@ -43,9 +44,8 @@ const ChangePasswordDialog: React.FC = (props: Props) => { requestState.setLoading(); try { - const user = userService.getState().user as User; - await userService.patchUser({ - id: user.id, + userStore.patchUser({ + id: userStore.getCurrentUser().id, password: newPassword, }); onClose(); diff --git a/web/src/components/CreateUserDialog.tsx b/web/src/components/CreateUserDialog.tsx new file mode 100644 index 0000000..bec12b2 --- /dev/null +++ b/web/src/components/CreateUserDialog.tsx @@ -0,0 +1,201 @@ +import { Button, Input, Modal, ModalDialog, Radio, RadioGroup } from "@mui/joy"; +import { useEffect, useState } from "react"; +import { toast } from "react-hot-toast"; +import useLoading from "../hooks/useLoading"; +import useUserStore from "../stores/v1/user"; +import Icon from "./Icon"; + +interface Props { + user?: User; + onClose: () => void; + onConfirm?: () => void; +} + +interface State { + userCreate: UserCreate; +} + +const roles: Role[] = ["USER", "ADMIN"]; + +const CreateUserDialog: React.FC = (props: Props) => { + const { onClose, onConfirm, user } = props; + const userStore = useUserStore(); + const [state, setState] = useState({ + userCreate: { + email: "", + nickname: "", + password: "", + role: "USER", + }, + }); + const requestState = useLoading(false); + const isEditing = !!user; + + useEffect(() => { + if (user) { + setState({ + ...state, + userCreate: Object.assign(state.userCreate, { + email: user.email, + nickname: user.nickname, + password: "", + role: user.role, + }), + }); + } + }, [user]); + + const setPartialState = (partialState: Partial) => { + setState({ + ...state, + ...partialState, + }); + }; + + const handleEmailInputChange = (e: React.ChangeEvent) => { + setPartialState({ + userCreate: Object.assign(state.userCreate, { + email: e.target.value.toLowerCase(), + }), + }); + }; + + const handleNicknameInputChange = (e: React.ChangeEvent) => { + setPartialState({ + userCreate: Object.assign(state.userCreate, { + nickname: e.target.value, + }), + }); + }; + + const handlePasswordInputChange = (e: React.ChangeEvent) => { + setPartialState({ + userCreate: Object.assign(state.userCreate, { + password: e.target.value, + }), + }); + }; + + const handleRoleInputChange = (e: React.ChangeEvent) => { + setPartialState({ + userCreate: Object.assign(state.userCreate, { + visibility: e.target.value, + }), + }); + }; + + const handleSaveBtnClick = async () => { + if (!state.userCreate.email) { + toast.error("Email is required"); + return; + } + + try { + if (user) { + const userPatch: UserPatch = { + id: user.id, + }; + if (user.email !== state.userCreate.email) { + userPatch.email = state.userCreate.email; + } + if (user.nickname !== state.userCreate.nickname) { + userPatch.nickname = state.userCreate.nickname; + } + if (state.userCreate.password) { + userPatch.password = state.userCreate.password; + } + if (user.role !== state.userCreate.role) { + userPatch.role = state.userCreate.role; + } + await userStore.patchUser(userPatch); + } else { + await userStore.createUser(state.userCreate); + } + + if (onConfirm) { + onConfirm(); + } else { + onClose(); + } + } catch (error: any) { + console.error(error); + toast.error(error.response.data.message); + } + }; + + return ( + + +
+ {isEditing ? "Edit User" : "Create User"} + +
+
+
+ + Email * + +
+ +
+
+
+ + Nickname * + + +
+
+ + Password + {!isEditing && *} + + +
+
+ + Role * + +
+ + {roles.map((role) => ( + + ))} + +
+
+
+ + +
+
+
+
+ ); +}; + +export default CreateUserDialog; diff --git a/web/src/components/EditUserinfoDialog.tsx b/web/src/components/EditUserinfoDialog.tsx index 3c9d740..4ec66ca 100644 --- a/web/src/components/EditUserinfoDialog.tsx +++ b/web/src/components/EditUserinfoDialog.tsx @@ -2,8 +2,7 @@ import { Button, Input, Modal, ModalDialog } from "@mui/joy"; import { useState } from "react"; import { toast } from "react-hot-toast"; import useLoading from "../hooks/useLoading"; -import { userService } from "../services"; -import { useAppSelector } from "../stores"; +import useUserStore from "../stores/v1/user"; import Icon from "./Icon"; interface Props { @@ -12,9 +11,10 @@ interface Props { const EditUserinfoDialog: React.FC = (props: Props) => { const { onClose } = props; - const user = useAppSelector((state) => state.user.user as User); - const [email, setEmail] = useState(user.email); - const [nickname, setNickname] = useState(user.nickname); + const userStore = useUserStore(); + const currentUser = userStore.getCurrentUser(); + const [email, setEmail] = useState(currentUser.email); + const [nickname, setNickname] = useState(currentUser.nickname); const requestState = useLoading(false); const handleCloseBtnClick = () => { @@ -39,14 +39,13 @@ const EditUserinfoDialog: React.FC = (props: Props) => { requestState.setLoading(); try { - const user = userService.getState().user as User; - await userService.patchUser({ - id: user.id, + await userStore.patchUser({ + id: currentUser.id, email, nickname, }); onClose(); - toast("Password changed"); + toast("User information updated"); } catch (error: any) { console.error(error); toast.error(error.response.data.message); diff --git a/web/src/components/Header.tsx b/web/src/components/Header.tsx index ff30c77..1c68c7d 100644 --- a/web/src/components/Header.tsx +++ b/web/src/components/Header.tsx @@ -1,14 +1,14 @@ import { Avatar } from "@mui/joy"; import { useState } from "react"; import { Link, useNavigate } from "react-router-dom"; -import { useAppSelector } from "../stores"; +import useUserStore from "../stores/v1/user"; import Icon from "./Icon"; import Dropdown from "./common/Dropdown"; import AboutDialog from "./AboutDialog"; const Header: React.FC = () => { const navigate = useNavigate(); - const user = useAppSelector((state) => state.user).user as User; + const currentUser = useUserStore().getCurrentUser(); const [showAboutDialog, setShowAboutDialog] = useState(false); const handleSignOutButtonClick = async () => { @@ -30,7 +30,7 @@ const Header: React.FC = () => { trigger={ } diff --git a/web/src/components/ShortcutView.tsx b/web/src/components/ShortcutView.tsx index 623d98a..d1184c7 100644 --- a/web/src/components/ShortcutView.tsx +++ b/web/src/components/ShortcutView.tsx @@ -4,8 +4,8 @@ import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import toast from "react-hot-toast"; import { shortcutService } from "../services"; -import { useAppSelector } from "../stores"; import useFaviconStore from "../stores/v1/favicon"; +import useUserStore from "../stores/v1/user"; import { absolutifyLink } from "../helpers/utils"; import { showCommonDialog } from "./Alert"; import Icon from "./Icon"; @@ -21,11 +21,11 @@ interface Props { const ShortcutView = (props: Props) => { const { shortcut, handleEdit } = props; const { t } = useTranslation(); - const user = useAppSelector((state) => state.user.user as User); + const currentUser = useUserStore().getCurrentUser(); const faviconStore = useFaviconStore(); const [favicon, setFavicon] = useState(undefined); const [showQRCodeDialog, setShowQRCodeDialog] = useState(false); - const havePermission = user.role === "ADMIN" || shortcut.creatorId === user.id; + const havePermission = currentUser.role === "ADMIN" || shortcut.creatorId === currentUser.id; const shortifyLink = absolutifyLink(`/s/${shortcut.name}`); useEffect(() => { diff --git a/web/src/components/setting/AccountSection.tsx b/web/src/components/setting/AccountSection.tsx index 35f9edb..72f8884 100644 --- a/web/src/components/setting/AccountSection.tsx +++ b/web/src/components/setting/AccountSection.tsx @@ -1,26 +1,26 @@ import { Button } from "@mui/joy"; import { useState } from "react"; -import { useAppSelector } from "../../stores"; +import useUserStore from "../../stores/v1/user"; import ChangePasswordDialog from "../ChangePasswordDialog"; import EditUserinfoDialog from "../EditUserinfoDialog"; const AccountSection: React.FC = () => { - const user = useAppSelector((state) => state.user).user as User; + const currentUser = useUserStore().getCurrentUser(); const [showEditUserinfoDialog, setShowEditUserinfoDialog] = useState(false); const [showChangePasswordDialog, setShowChangePasswordDialog] = useState(false); - const isAdmin = user.role === "ADMIN"; + const isAdmin = currentUser.role === "ADMIN"; return ( <>
-

Account

+

Account

- {user.nickname} + {currentUser.nickname} {isAdmin && Admin}

Email: - {user.email} + {currentUser.email}

+
+
+
+
+
+ + + + + + + + + + + {userList.map((user) => ( + + + + + + + ))} + +
+ Nickname + + Email + + Role + + Edit +
{user.nickname}{user.email}{user.role} + +
+
+
+
+ + + + {showCreateUserDialog && } + + ); +}; + +export default MemberSection; diff --git a/web/src/components/setting/WorkspaceSection.tsx b/web/src/components/setting/WorkspaceSection.tsx index 3f84c53..44606e9 100644 --- a/web/src/components/setting/WorkspaceSection.tsx +++ b/web/src/components/setting/WorkspaceSection.tsx @@ -18,10 +18,9 @@ const WorkspaceSection: React.FC = () => { return (
-

Workspace settings

+

Workspace settings

handleDisallowSignUpChange(event.target.checked)} diff --git a/web/src/helpers/api.ts b/web/src/helpers/api.ts index 8e1a65c..1885229 100644 --- a/web/src/helpers/api.ts +++ b/web/src/helpers/api.ts @@ -35,6 +35,10 @@ export function getUserById(id: number) { return axios.get(`/api/v1/user/${id}`); } +export function createUser(userCreate: UserCreate) { + return axios.post("/api/v1/user", userCreate); +} + export function patchUser(userPatch: UserPatch) { return axios.patch(`/api/v1/user/${userPatch.id}`, userPatch); } diff --git a/web/src/layouts/Root.tsx b/web/src/layouts/Root.tsx index 69a0405..d41d16d 100644 --- a/web/src/layouts/Root.tsx +++ b/web/src/layouts/Root.tsx @@ -1,12 +1,29 @@ -import { Outlet } from "react-router-dom"; +import { useEffect } from "react"; +import { Outlet, useNavigate } from "react-router-dom"; +import useUserStore from "../stores/v1/user"; import Header from "../components/Header"; const Root: React.FC = () => { + const navigate = useNavigate(); + const currentUser = useUserStore().getCurrentUser(); + + useEffect(() => { + if (!currentUser) { + navigate("/auth", { + replace: true, + }); + } + }, []); + return ( -
-
- -
+ <> + {currentUser && ( +
+
+ +
+ )} + ); }; diff --git a/web/src/main.tsx b/web/src/main.tsx index 54334e6..6740ae2 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -1,14 +1,21 @@ +import { CssVarsProvider } from "@mui/joy"; import { createRoot } from "react-dom/client"; +import { Toaster } from "react-hot-toast"; import { Provider } from "react-redux"; +import { RouterProvider } from "react-router-dom"; import store from "./stores"; -import App from "./App"; +import router from "./routers"; import "./i18n"; import "./css/index.css"; const container = document.getElementById("root"); const root = createRoot(container as HTMLElement); + root.render( - + + + + ); diff --git a/web/src/pages/Account.tsx b/web/src/pages/Account.tsx index 3487257..95496e5 100644 --- a/web/src/pages/Account.tsx +++ b/web/src/pages/Account.tsx @@ -1,21 +1,21 @@ import { Button } from "@mui/joy"; import { useState } from "react"; -import { useAppSelector } from "../stores"; +import useUserStore from "../stores/v1/user"; import ChangePasswordDialog from "../components/ChangePasswordDialog"; import EditUserinfoDialog from "../components/EditUserinfoDialog"; const Account: React.FC = () => { - const user = useAppSelector((state) => state.user).user as User; + const currentUser = useUserStore().getCurrentUser(); const [showEditUserinfoDialog, setShowEditUserinfoDialog] = useState(false); const [showChangePasswordDialog, setShowChangePasswordDialog] = useState(false); return ( <>
-

{user.nickname}

+

{currentUser.nickname}

Email: - {user.email} + {currentUser.email}