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) => ( + + ))} + + + + + + Cancel + + + Save + + + + + + ); +}; + +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={ - {user.nickname} + {currentUser.nickname} } 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} setShowEditUserinfoDialog(true)}> diff --git a/web/src/components/setting/UserSection.tsx b/web/src/components/setting/UserSection.tsx new file mode 100644 index 0000000..c931994 --- /dev/null +++ b/web/src/components/setting/UserSection.tsx @@ -0,0 +1,95 @@ +import { useEffect, useState } from "react"; +import { Button } from "@mui/joy"; +import CreateUserDialog from "../CreateUserDialog"; +import useUserStore from "../../stores/v1/user"; + +const MemberSection = () => { + const userStore = useUserStore(); + const [showCreateUserDialog, setShowCreateUserDialog] = useState(false); + const [currentEditingUser, setCurrentEditingUser] = useState(undefined); + const userList = Object.values(userStore.userMap); + + useEffect(() => { + userStore.fetchUserList(); + }, []); + + const handleCreateUserDialogClose = () => { + setShowCreateUserDialog(false); + setCurrentEditingUser(undefined); + }; + + return ( + <> + + + + + Users + + A list of all the users in your workspace including their nickname, email and role. + + + + { + setShowCreateUserDialog(true); + setCurrentEditingUser(undefined); + }} + > + Add user + + + + + + + + + + + Nickname + + + Email + + + Role + + + Edit + + + + + {userList.map((user) => ( + + {user.nickname} + {user.email} + {user.role} + + { + setCurrentEditingUser(user); + setShowCreateUserDialog(true); + }} + > + Edit + + + + ))} + + + + + + + + + {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} setShowEditUserinfoDialog(true)}> diff --git a/web/src/pages/Home.tsx b/web/src/pages/Home.tsx index 607f831..c00c31f 100644 --- a/web/src/pages/Home.tsx +++ b/web/src/pages/Home.tsx @@ -2,6 +2,7 @@ import { Button, Tab, TabList, Tabs } from "@mui/joy"; import { useEffect, useState } from "react"; import { shortcutService } from "../services"; import { useAppSelector } from "../stores"; +import useUserStore from "../stores/v1/user"; import useLoading from "../hooks/useLoading"; import Icon from "../components/Icon"; import ShortcutListView from "../components/ShortcutListView"; @@ -13,13 +14,14 @@ interface State { const Home: React.FC = () => { const loadingState = useLoading(); + const currentUser = useUserStore().getCurrentUser(); const { shortcutList } = useAppSelector((state) => state.shortcut); - const user = useAppSelector((state) => state.user).user as User; const [state, setState] = useState({ showCreateShortcutDialog: false, }); const [selectedFilter, setSelectFilter] = useState<"ALL" | "PRIVATE">("ALL"); - const filteredShortcutList = selectedFilter === "ALL" ? shortcutList : shortcutList.filter((shortcut) => shortcut.creatorId === user.id); + const filteredShortcutList = + selectedFilter === "ALL" ? shortcutList : shortcutList.filter((shortcut) => shortcut.creatorId === currentUser.id); useEffect(() => { Promise.all([shortcutService.getMyAllShortcuts()]).finally(() => { diff --git a/web/src/pages/Setting.tsx b/web/src/pages/Setting.tsx index 76fd4bb..aeefe68 100644 --- a/web/src/pages/Setting.tsx +++ b/web/src/pages/Setting.tsx @@ -1,15 +1,21 @@ -import { useAppSelector } from "../stores"; +import useUserStore from "../stores/v1/user"; import AccountSection from "../components/setting/AccountSection"; import WorkspaceSection from "../components/setting/WorkspaceSection"; +import UserSection from "../components/setting/UserSection"; const Setting: React.FC = () => { - const user = useAppSelector((state) => state.user).user as User; - const isAdmin = user.role === "ADMIN"; + const currentUser = useUserStore().getCurrentUser(); + const isAdmin = currentUser.role === "ADMIN"; return ( - {isAdmin && } + {isAdmin && ( + <> + + + > + )} ); }; diff --git a/web/src/pages/SignIn.tsx b/web/src/pages/SignIn.tsx index 25e1ebd..0e4fcc9 100644 --- a/web/src/pages/SignIn.tsx +++ b/web/src/pages/SignIn.tsx @@ -3,12 +3,13 @@ import React, { useEffect, useState } from "react"; import { Link, useNavigate } from "react-router-dom"; import { toast } from "react-hot-toast"; import * as api from "../helpers/api"; -import { userService } from "../services"; import { useAppSelector } from "../stores"; import useLoading from "../hooks/useLoading"; +import useUserStore from "../stores/v1/user"; const SignIn: React.FC = () => { const navigate = useNavigate(); + const userStore = useUserStore(); const { workspaceProfile: { disallowSignUp }, } = useAppSelector((state) => state.global); @@ -18,7 +19,7 @@ const SignIn: React.FC = () => { const allowConfirm = email.length > 0 && password.length > 0; useEffect(() => { - userService.doSignOut(); + api.signout(); }, []); const handleEmailInputChanged = (e: React.ChangeEvent) => { @@ -39,7 +40,7 @@ const SignIn: React.FC = () => { try { actionBtnLoadingState.setLoading(); await api.signin(email, password); - const user = await userService.doSignIn(); + const user = await userStore.fetchCurrentUser(); if (user) { navigate("/", { replace: true, diff --git a/web/src/pages/SignUp.tsx b/web/src/pages/SignUp.tsx index 0d6f0ff..065af24 100644 --- a/web/src/pages/SignUp.tsx +++ b/web/src/pages/SignUp.tsx @@ -3,11 +3,16 @@ import React, { useEffect, useState } from "react"; import { Link, useNavigate } from "react-router-dom"; import { toast } from "react-hot-toast"; import * as api from "../helpers/api"; -import { userService } from "../services"; +import { globalService } from "../services"; import useLoading from "../hooks/useLoading"; +import useUserStore from "../stores/v1/user"; const SignUp: React.FC = () => { const navigate = useNavigate(); + const userStore = useUserStore(); + const { + workspaceProfile: { disallowSignUp }, + } = globalService.getState(); const [email, setEmail] = useState(""); const [nickname, setNickname] = useState(""); const [password, setPassword] = useState(""); @@ -15,7 +20,15 @@ const SignUp: React.FC = () => { const allowConfirm = email.length > 0 && nickname.length > 0 && password.length > 0; useEffect(() => { - userService.doSignOut(); + if (disallowSignUp) { + return navigate("/auth", { + replace: true, + }); + } + }, []); + + useEffect(() => { + api.signout(); }, []); const handleEmailInputChanged = (e: React.ChangeEvent) => { @@ -41,7 +54,7 @@ const SignUp: React.FC = () => { try { actionBtnLoadingState.setLoading(); await api.signup(email, nickname, password); - const user = await userService.doSignIn(); + const user = await userStore.fetchCurrentUser(); if (user) { navigate("/", { replace: true, diff --git a/web/src/routers/index.tsx b/web/src/routers/index.tsx index 0eb114e..5c385a4 100644 --- a/web/src/routers/index.tsx +++ b/web/src/routers/index.tsx @@ -1,83 +1,37 @@ -import { createBrowserRouter, redirect } from "react-router-dom"; -import { isNullorUndefined } from "../helpers/utils"; -import { globalService, userService } from "../services"; +import { createBrowserRouter } from "react-router-dom"; import Root from "../layouts/Root"; import SignIn from "../pages/SignIn"; import SignUp from "../pages/SignUp"; import Home from "../pages/Home"; import Setting from "../pages/Setting"; +import App from "../App"; const router = createBrowserRouter([ - { - path: "/auth", - element: , - loader: async () => { - try { - await globalService.initialState(); - } catch (error) { - // do nth - } - - return null; - }, - }, - { - path: "/auth/signup", - element: , - loader: async () => { - try { - await globalService.initialState(); - } catch (error) { - // do nth - } - - const { - workspaceProfile: { disallowSignUp }, - } = globalService.getState(); - if (disallowSignUp) { - return redirect("/auth"); - } - - return null; - }, - }, { path: "/", - element: , + element: , children: [ { - path: "", - element: , - loader: async () => { - try { - await userService.initialState(); - } catch (error) { - // do nth - } - - const { user } = userService.getState(); - if (isNullorUndefined(user)) { - return redirect("/auth"); - } - return null; - }, + path: "auth", + element: , }, { - path: "/setting", - element: , - loader: async () => { - try { - await userService.initialState(); - } catch (error) { - // do nth - } - - const { user } = userService.getState(); - if (isNullorUndefined(user)) { - return redirect("/auth"); - } - return null; - }, + path: "auth/signup", + element: , + }, + { + path: "", + element: , + children: [ + { + path: "", + element: , + }, + { + path: "/setting", + element: , + }, + ], }, ], }, diff --git a/web/src/services/globalService.ts b/web/src/services/globalService.ts index fb9d54d..3e30407 100644 --- a/web/src/services/globalService.ts +++ b/web/src/services/globalService.ts @@ -1,7 +1,6 @@ import * as api from "../helpers/api"; import store from "../stores"; import { setGlobalState } from "../stores/modules/global"; -import userService from "./userService"; const globalService = { getState: () => { @@ -15,12 +14,6 @@ const globalService = { } catch (error) { // do nth } - - try { - await userService.initialState(); - } catch (error) { - // do nth - } }, }; diff --git a/web/src/services/index.ts b/web/src/services/index.ts index 2c45b80..c69b14e 100644 --- a/web/src/services/index.ts +++ b/web/src/services/index.ts @@ -1,5 +1,4 @@ import globalService from "./globalService"; import shortcutService from "./shortcutService"; -import userService from "./userService"; -export { globalService, shortcutService, userService }; +export { globalService, shortcutService }; diff --git a/web/src/services/userService.ts b/web/src/services/userService.ts deleted file mode 100644 index ac90734..0000000 --- a/web/src/services/userService.ts +++ /dev/null @@ -1,66 +0,0 @@ -import * as api from "../helpers/api"; -import store from "../stores"; -import { setUser, patchUser } from "../stores/modules/user"; - -export const convertResponseModelUser = (user: User): User => { - return { - ...user, - createdTs: user.createdTs * 1000, - updatedTs: user.updatedTs * 1000, - }; -}; - -const userService = { - getState: () => { - return store.getState().user; - }, - - initialState: async () => { - try { - const user = (await api.getMyselfUser()).data; - if (user) { - store.dispatch(setUser(convertResponseModelUser(user))); - } - } catch (error) { - // do nth - } - }, - - doSignIn: async () => { - const user = (await api.getMyselfUser()).data; - if (user) { - store.dispatch(setUser(convertResponseModelUser(user))); - } else { - userService.doSignOut(); - } - return user; - }, - - doSignOut: async () => { - store.dispatch(setUser()); - await api.signout(); - }, - - getUserById: async (userId: UserId) => { - const user = (await api.getUserById(userId)).data; - if (user) { - return convertResponseModelUser(user); - } else { - return undefined; - } - }, - - patchUser: async (userPatch: UserPatch): Promise => { - const data = (await api.patchUser(userPatch)).data; - if (userPatch.id === store.getState().user.user?.id) { - const user = convertResponseModelUser(data); - store.dispatch(patchUser(user)); - } - }, - - deleteUser: async (userDelete: UserDelete) => { - await api.deleteUser(userDelete); - }, -}; - -export default userService; diff --git a/web/src/stores/index.ts b/web/src/stores/index.ts index 5b38fde..19e8125 100644 --- a/web/src/stores/index.ts +++ b/web/src/stores/index.ts @@ -1,13 +1,11 @@ import { configureStore } from "@reduxjs/toolkit"; import { TypedUseSelectorHook, useSelector } from "react-redux"; import globalReducer from "./modules/global"; -import userReducer from "./modules/user"; import shortcutReducer from "./modules/shortcut"; const store = configureStore({ reducer: { global: globalReducer, - user: userReducer, shortcut: shortcutReducer, }, }); diff --git a/web/src/stores/modules/user.ts b/web/src/stores/modules/user.ts deleted file mode 100644 index a1e58fc..0000000 --- a/web/src/stores/modules/user.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { createSlice, PayloadAction } from "@reduxjs/toolkit"; - -interface State { - user?: User; -} - -const userSlice = createSlice({ - name: "user", - initialState: {} as State, - reducers: { - setUser: (state, action: PayloadAction) => { - return { - ...state, - user: action.payload, - }; - }, - patchUser: (state, action: PayloadAction>) => { - return { - ...state, - user: { - ...state.user, - ...action.payload, - } as User, - }; - }, - }, -}); - -export const { setUser, patchUser } = userSlice.actions; - -export default userSlice.reducer; diff --git a/web/src/stores/v1/user.ts b/web/src/stores/v1/user.ts new file mode 100644 index 0000000..50db452 --- /dev/null +++ b/web/src/stores/v1/user.ts @@ -0,0 +1,83 @@ +import { create } from "zustand"; +import * as api from "../../helpers/api"; + +const convertResponseModelUser = (user: User): User => { + return { + ...user, + createdTs: user.createdTs * 1000, + updatedTs: user.updatedTs * 1000, + }; +}; + +interface UserState { + userMap: { + [key: UserId]: User; + }; + currentUserId?: UserId; + fetchUserList: () => Promise; + fetchCurrentUser: () => Promise; + getOrFetchUserById: (id: UserId) => Promise; + getUserById: (id: UserId) => User; + getCurrentUser: () => User; + createUser: (userCreate: UserCreate) => Promise; + patchUser: (userPatch: UserPatch) => Promise; +} + +const useUserStore = create()((set, get) => ({ + userMap: {}, + fetchUserList: async () => { + const { data: userList } = await api.getUserList(); + const userMap = get().userMap; + userList.forEach((user) => { + userMap[user.id] = convertResponseModelUser(user); + }); + set(userMap); + return userList; + }, + fetchCurrentUser: async () => { + const { data } = await api.getMyselfUser(); + const user = convertResponseModelUser(data); + const userMap = get().userMap; + userMap[user.id] = user; + set({ userMap, currentUserId: user.id }); + return user; + }, + getOrFetchUserById: async (id: UserId) => { + const userMap = get().userMap; + if (userMap[id]) { + return userMap[id] as User; + } + + const { data } = await api.getUserById(id); + const user = convertResponseModelUser(data); + userMap[id] = user; + set(userMap); + return user; + }, + createUser: async (userCreate: UserCreate) => { + const { data } = await api.createUser(userCreate); + const user = convertResponseModelUser(data); + const userMap = get().userMap; + userMap[user.id] = user; + set(userMap); + return user; + }, + patchUser: async (userPatch: UserPatch) => { + const { data } = await api.patchUser(userPatch); + const user = convertResponseModelUser(data); + const userMap = get().userMap; + userMap[user.id] = user; + set(userMap); + }, + getUserById: (id: UserId) => { + const userMap = get().userMap; + return userMap[id] as User; + }, + getCurrentUser: () => { + const userMap = get().userMap; + const currentUserId = get().currentUserId; + return userMap[currentUserId as UserId]; + }, +})); + +export default useUserStore; diff --git a/web/src/types/modules/user.d.ts b/web/src/types/modules/user.d.ts index 5f2757b..9448dd0 100644 --- a/web/src/types/modules/user.d.ts +++ b/web/src/types/modules/user.d.ts @@ -14,6 +14,13 @@ interface User { role: Role; } +interface UserCreate { + email: string; + nickname: string; + password: string; + role: Role; +} + interface UserPatch { id: UserId; @@ -21,6 +28,7 @@ interface UserPatch { email?: string; nickname?: string; password?: string; + role?: Role; } interface UserDelete {
Account
- {user.nickname} + {currentUser.nickname} {isAdmin && Admin}
Email: - {user.email} + {currentUser.email}
Users
+ A list of all the users in your workspace including their nickname, email and role. +
Workspace settings
{user.nickname}
{currentUser.nickname}