feat: add member list in setting

This commit is contained in:
Steven 2023-07-09 00:45:26 +08:00
parent c00f7d0852
commit 5db3506cba
26 changed files with 614 additions and 238 deletions

View File

@ -56,7 +56,7 @@ type CreateUserRequest struct {
Email string `json:"email"` Email string `json:"email"`
Nickname string `json:"nickname"` Nickname string `json:"nickname"`
Password string `json:"password"` Password string `json:"password"`
Role Role `json:"-"` Role Role `json:"role"`
} }
func (create CreateUserRequest) Validate() error { func (create CreateUserRequest) Validate() error {
@ -78,9 +78,56 @@ type PatchUserRequest struct {
Email *string `json:"email"` Email *string `json:"email"`
Nickname *string `json:"nickname"` Nickname *string `json:"nickname"`
Password *string `json:"password"` Password *string `json:"password"`
Role *Role `json:"role"`
} }
func (s *APIV1Service) registerUserRoutes(g *echo.Group) { 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 { g.GET("/user", func(c echo.Context) error {
ctx := c.Request().Context() ctx := c.Request().Context()
list, err := s.Store.ListUsers(ctx, &store.FindUser{}) list, err := s.Store.ListUsers(ctx, &store.FindUser{})
@ -140,7 +187,16 @@ func (s *APIV1Service) registerUserRoutes(g *echo.Group) {
if !ok { if !ok {
return echo.NewHTTPError(http.StatusUnauthorized, "missing user in session") return echo.NewHTTPError(http.StatusUnauthorized, "missing user in session")
} }
if currentUserID != userID { currentUser, err := s.Store.GetUser(ctx, &store.FindUser{
ID: &currentUserID,
})
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) 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{ updateUser := &store.UpdateUser{
ID: currentUserID, ID: userID,
} }
if userPatch.Email != nil { if userPatch.Email != nil {
if !validateEmail(*userPatch.Email) { if !validateEmail(*userPatch.Email) {
@ -170,6 +226,14 @@ func (s *APIV1Service) registerUserRoutes(g *echo.Group) {
passwordHashStr := string(passwordHash) passwordHashStr := string(passwordHash)
updateUser.PasswordHash = &passwordHashStr 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) user, err := s.Store.UpdateUser(ctx, updateUser)
if err != nil { if err != nil {
@ -202,6 +266,18 @@ func (s *APIV1Service) registerUserRoutes(g *echo.Group) {
if err != nil { if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("user id is not a number: %s", c.Param("id"))).SetInternal(err) 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{ if err := s.Store.DeleteUser(ctx, &store.DeleteUser{
ID: userID, ID: userID,

View File

@ -1,15 +1,33 @@
import { CssVarsProvider } from "@mui/joy/styles"; import { useEffect, useState } from "react";
import { Toaster } from "react-hot-toast"; import { Outlet } from "react-router-dom";
import { RouterProvider } from "react-router-dom"; import { globalService } from "./services";
import router from "./routers"; import useUserStore from "./stores/v1/user";
function App() { function App() {
return ( const userStore = useUserStore();
<CssVarsProvider> const [loading, setLoading] = useState(true);
<RouterProvider router={router} />
<Toaster position="top-center" /> useEffect(() => {
</CssVarsProvider> const initialState = async () => {
); try {
await globalService.initialState();
} catch (error) {
// do nothing
}
try {
await userStore.fetchCurrentUser();
} catch (error) {
// do nothing.
}
setLoading(false);
};
initialState();
}, []);
return <>{!loading && <Outlet />}</>;
} }
export default App; export default App;

View File

@ -2,7 +2,7 @@ import { Button, Input, Modal, ModalDialog } from "@mui/joy";
import { useState } from "react"; import { useState } from "react";
import { toast } from "react-hot-toast"; import { toast } from "react-hot-toast";
import useLoading from "../hooks/useLoading"; import useLoading from "../hooks/useLoading";
import { userService } from "../services"; import useUserStore from "../stores/v1/user";
import Icon from "./Icon"; import Icon from "./Icon";
interface Props { interface Props {
@ -11,6 +11,7 @@ interface Props {
const ChangePasswordDialog: React.FC<Props> = (props: Props) => { const ChangePasswordDialog: React.FC<Props> = (props: Props) => {
const { onClose } = props; const { onClose } = props;
const userStore = useUserStore();
const [newPassword, setNewPassword] = useState(""); const [newPassword, setNewPassword] = useState("");
const [newPasswordAgain, setNewPasswordAgain] = useState(""); const [newPasswordAgain, setNewPasswordAgain] = useState("");
const requestState = useLoading(false); const requestState = useLoading(false);
@ -43,9 +44,8 @@ const ChangePasswordDialog: React.FC<Props> = (props: Props) => {
requestState.setLoading(); requestState.setLoading();
try { try {
const user = userService.getState().user as User; userStore.patchUser({
await userService.patchUser({ id: userStore.getCurrentUser().id,
id: user.id,
password: newPassword, password: newPassword,
}); });
onClose(); onClose();

View File

@ -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: Props) => {
const { onClose, onConfirm, user } = props;
const userStore = useUserStore();
const [state, setState] = useState<State>({
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<State>) => {
setState({
...state,
...partialState,
});
};
const handleEmailInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setPartialState({
userCreate: Object.assign(state.userCreate, {
email: e.target.value.toLowerCase(),
}),
});
};
const handleNicknameInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setPartialState({
userCreate: Object.assign(state.userCreate, {
nickname: e.target.value,
}),
});
};
const handlePasswordInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setPartialState({
userCreate: Object.assign(state.userCreate, {
password: e.target.value,
}),
});
};
const handleRoleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
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 (
<Modal open={true}>
<ModalDialog>
<div className="flex flex-row justify-between items-center w-80 sm:w-96 mb-4">
<span className="text-lg font-medium">{isEditing ? "Edit User" : "Create User"}</span>
<Button variant="plain" onClick={onClose}>
<Icon.X className="w-5 h-auto text-gray-600" />
</Button>
</div>
<div>
<div className="w-full flex flex-col justify-start items-start mb-3">
<span className="mb-2">
Email <span className="text-red-600">*</span>
</span>
<div className="relative w-full">
<Input
className="w-full"
type="email"
placeholder="Unique user email"
value={state.userCreate.email}
onChange={handleEmailInputChange}
/>
</div>
</div>
<div className="w-full flex flex-col justify-start items-start mb-3">
<span className="mb-2">
Nickname <span className="text-red-600">*</span>
</span>
<Input
className="w-full"
type="text"
placeholder="Nickname"
value={state.userCreate.nickname}
onChange={handleNicknameInputChange}
/>
</div>
<div className="w-full flex flex-col justify-start items-start mb-3">
<span className="mb-2">
Password
{!isEditing && <span className="text-red-600"> *</span>}
</span>
<Input
className="w-full"
type="password"
placeholder=""
value={state.userCreate.password}
onChange={handlePasswordInputChange}
/>
</div>
<div className="w-full flex flex-col justify-start items-start mb-3">
<span className="mb-2">
Role <span className="text-red-600">*</span>
</span>
<div className="w-full flex flex-row justify-start items-center text-base">
<RadioGroup orientation="horizontal" value={state.userCreate.role} onChange={handleRoleInputChange}>
{roles.map((role) => (
<Radio key={role} value={role} label={role} />
))}
</RadioGroup>
</div>
</div>
<div className="w-full flex flex-row justify-end items-center mt-4 space-x-2">
<Button color="neutral" variant="plain" disabled={requestState.isLoading} loading={requestState.isLoading} onClick={onClose}>
Cancel
</Button>
<Button color="primary" disabled={requestState.isLoading} loading={requestState.isLoading} onClick={handleSaveBtnClick}>
Save
</Button>
</div>
</div>
</ModalDialog>
</Modal>
);
};
export default CreateUserDialog;

View File

@ -2,8 +2,7 @@ import { Button, Input, Modal, ModalDialog } from "@mui/joy";
import { useState } from "react"; import { useState } from "react";
import { toast } from "react-hot-toast"; import { toast } from "react-hot-toast";
import useLoading from "../hooks/useLoading"; import useLoading from "../hooks/useLoading";
import { userService } from "../services"; import useUserStore from "../stores/v1/user";
import { useAppSelector } from "../stores";
import Icon from "./Icon"; import Icon from "./Icon";
interface Props { interface Props {
@ -12,9 +11,10 @@ interface Props {
const EditUserinfoDialog: React.FC<Props> = (props: Props) => { const EditUserinfoDialog: React.FC<Props> = (props: Props) => {
const { onClose } = props; const { onClose } = props;
const user = useAppSelector((state) => state.user.user as User); const userStore = useUserStore();
const [email, setEmail] = useState(user.email); const currentUser = userStore.getCurrentUser();
const [nickname, setNickname] = useState(user.nickname); const [email, setEmail] = useState(currentUser.email);
const [nickname, setNickname] = useState(currentUser.nickname);
const requestState = useLoading(false); const requestState = useLoading(false);
const handleCloseBtnClick = () => { const handleCloseBtnClick = () => {
@ -39,14 +39,13 @@ const EditUserinfoDialog: React.FC<Props> = (props: Props) => {
requestState.setLoading(); requestState.setLoading();
try { try {
const user = userService.getState().user as User; await userStore.patchUser({
await userService.patchUser({ id: currentUser.id,
id: user.id,
email, email,
nickname, nickname,
}); });
onClose(); onClose();
toast("Password changed"); toast("User information updated");
} catch (error: any) { } catch (error: any) {
console.error(error); console.error(error);
toast.error(error.response.data.message); toast.error(error.response.data.message);

View File

@ -1,14 +1,14 @@
import { Avatar } from "@mui/joy"; import { Avatar } from "@mui/joy";
import { useState } from "react"; import { useState } from "react";
import { Link, useNavigate } from "react-router-dom"; import { Link, useNavigate } from "react-router-dom";
import { useAppSelector } from "../stores"; import useUserStore from "../stores/v1/user";
import Icon from "./Icon"; import Icon from "./Icon";
import Dropdown from "./common/Dropdown"; import Dropdown from "./common/Dropdown";
import AboutDialog from "./AboutDialog"; import AboutDialog from "./AboutDialog";
const Header: React.FC = () => { const Header: React.FC = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const user = useAppSelector((state) => state.user).user as User; const currentUser = useUserStore().getCurrentUser();
const [showAboutDialog, setShowAboutDialog] = useState<boolean>(false); const [showAboutDialog, setShowAboutDialog] = useState<boolean>(false);
const handleSignOutButtonClick = async () => { const handleSignOutButtonClick = async () => {
@ -30,7 +30,7 @@ const Header: React.FC = () => {
trigger={ trigger={
<button className="flex flex-row justify-end items-center cursor-pointer"> <button className="flex flex-row justify-end items-center cursor-pointer">
<Avatar size="sm" variant="plain" /> <Avatar size="sm" variant="plain" />
<span>{user.nickname}</span> <span>{currentUser.nickname}</span>
<Icon.ChevronDown className="ml-2 w-5 h-auto text-gray-600" /> <Icon.ChevronDown className="ml-2 w-5 h-auto text-gray-600" />
</button> </button>
} }

View File

@ -4,8 +4,8 @@ import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import { shortcutService } from "../services"; import { shortcutService } from "../services";
import { useAppSelector } from "../stores";
import useFaviconStore from "../stores/v1/favicon"; import useFaviconStore from "../stores/v1/favicon";
import useUserStore from "../stores/v1/user";
import { absolutifyLink } from "../helpers/utils"; import { absolutifyLink } from "../helpers/utils";
import { showCommonDialog } from "./Alert"; import { showCommonDialog } from "./Alert";
import Icon from "./Icon"; import Icon from "./Icon";
@ -21,11 +21,11 @@ interface Props {
const ShortcutView = (props: Props) => { const ShortcutView = (props: Props) => {
const { shortcut, handleEdit } = props; const { shortcut, handleEdit } = props;
const { t } = useTranslation(); const { t } = useTranslation();
const user = useAppSelector((state) => state.user.user as User); const currentUser = useUserStore().getCurrentUser();
const faviconStore = useFaviconStore(); const faviconStore = useFaviconStore();
const [favicon, setFavicon] = useState<string | undefined>(undefined); const [favicon, setFavicon] = useState<string | undefined>(undefined);
const [showQRCodeDialog, setShowQRCodeDialog] = useState<boolean>(false); const [showQRCodeDialog, setShowQRCodeDialog] = useState<boolean>(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}`); const shortifyLink = absolutifyLink(`/s/${shortcut.name}`);
useEffect(() => { useEffect(() => {

View File

@ -1,26 +1,26 @@
import { Button } from "@mui/joy"; import { Button } from "@mui/joy";
import { useState } from "react"; import { useState } from "react";
import { useAppSelector } from "../../stores"; import useUserStore from "../../stores/v1/user";
import ChangePasswordDialog from "../ChangePasswordDialog"; import ChangePasswordDialog from "../ChangePasswordDialog";
import EditUserinfoDialog from "../EditUserinfoDialog"; import EditUserinfoDialog from "../EditUserinfoDialog";
const AccountSection: React.FC = () => { const AccountSection: React.FC = () => {
const user = useAppSelector((state) => state.user).user as User; const currentUser = useUserStore().getCurrentUser();
const [showEditUserinfoDialog, setShowEditUserinfoDialog] = useState<boolean>(false); const [showEditUserinfoDialog, setShowEditUserinfoDialog] = useState<boolean>(false);
const [showChangePasswordDialog, setShowChangePasswordDialog] = useState<boolean>(false); const [showChangePasswordDialog, setShowChangePasswordDialog] = useState<boolean>(false);
const isAdmin = user.role === "ADMIN"; const isAdmin = currentUser.role === "ADMIN";
return ( return (
<> <>
<div className="mx-auto max-w-4xl w-full px-3 py-6 flex flex-col justify-start items-start gap-y-2"> <div className="mx-auto max-w-4xl w-full px-3 py-6 flex flex-col justify-start items-start gap-y-2">
<p className="text-gray-400">Account</p> <p className="text-base font-semibold leading-6 text-gray-900">Account</p>
<p className="flex flex-row justify-start items-center mt-2"> <p className="flex flex-row justify-start items-center mt-2">
<span className="text-xl font-medium">{user.nickname}</span> <span className="text-xl">{currentUser.nickname}</span>
{isAdmin && <span className="ml-2 bg-blue-600 text-white px-2 leading-6 text-sm rounded-full">Admin</span>} {isAdmin && <span className="ml-2 bg-blue-600 text-white px-2 leading-6 text-sm rounded-full">Admin</span>}
</p> </p>
<p className="flex flex-row justify-start items-center"> <p className="flex flex-row justify-start items-center">
<span className="mr-3 text-gray-500 font-mono">Email: </span> <span className="mr-3 text-gray-500 font-mono">Email: </span>
{user.email} {currentUser.email}
</p> </p>
<div className="flex flex-row justify-start items-center gap-2 mt-2"> <div className="flex flex-row justify-start items-center gap-2 mt-2">
<Button variant="outlined" color="neutral" onClick={() => setShowEditUserinfoDialog(true)}> <Button variant="outlined" color="neutral" onClick={() => setShowEditUserinfoDialog(true)}>

View File

@ -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<boolean>(false);
const [currentEditingUser, setCurrentEditingUser] = useState<User | undefined>(undefined);
const userList = Object.values(userStore.userMap);
useEffect(() => {
userStore.fetchUserList();
}, []);
const handleCreateUserDialogClose = () => {
setShowCreateUserDialog(false);
setCurrentEditingUser(undefined);
};
return (
<>
<div className="mx-auto max-w-4xl w-full px-3 py-6 flex flex-col justify-start items-start space-y-4">
<div className="w-full">
<div className="sm:flex sm:items-center">
<div className="sm:flex-auto">
<p className="text-base font-semibold leading-6 text-gray-900">Users</p>
<p className="mt-2 text-sm text-gray-700">
A list of all the users in your workspace including their nickname, email and role.
</p>
</div>
<div className="mt-4 sm:ml-16 sm:mt-0 sm:flex-none">
<Button
onClick={() => {
setShowCreateUserDialog(true);
setCurrentEditingUser(undefined);
}}
>
Add user
</Button>
</div>
</div>
<div className="mt-2 flow-root">
<div className="overflow-x-auto">
<div className="inline-block min-w-full py-2 align-middle">
<table className="min-w-full divide-y divide-gray-300">
<thead>
<tr>
<th scope="col" className="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-gray-900">
Nickname
</th>
<th scope="col" className="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">
Email
</th>
<th scope="col" className="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">
Role
</th>
<th scope="col" className="relative py-3.5 pl-3 pr-4">
<span className="sr-only">Edit</span>
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200">
{userList.map((user) => (
<tr key={user.email}>
<td className="whitespace-nowrap py-4 pl-4 pr-3 text-sm text-gray-900">{user.nickname}</td>
<td className="whitespace-nowrap px-3 py-4 text-sm text-gray-500">{user.email}</td>
<td className="whitespace-nowrap px-3 py-4 text-sm text-gray-500">{user.role}</td>
<td className="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium">
<button
className="text-indigo-600 hover:text-indigo-900"
onClick={() => {
setCurrentEditingUser(user);
setShowCreateUserDialog(true);
}}
>
Edit
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
{showCreateUserDialog && <CreateUserDialog user={currentEditingUser} onClose={handleCreateUserDialogClose} />}
</>
);
};
export default MemberSection;

View File

@ -18,10 +18,9 @@ const WorkspaceSection: React.FC = () => {
return ( return (
<div className="mx-auto max-w-4xl w-full px-3 py-6 flex flex-col justify-start items-start space-y-4"> <div className="mx-auto max-w-4xl w-full px-3 py-6 flex flex-col justify-start items-start space-y-4">
<p className="text-gray-400">Workspace settings</p> <p className="text-base font-semibold leading-6 text-gray-900">Workspace settings</p>
<div className="w-full flex flex-col justify-start items-start"> <div className="w-full flex flex-col justify-start items-start">
<Checkbox <Checkbox
className="font-medium"
label="Disable user signup" label="Disable user signup"
checked={disallowSignUp} checked={disallowSignUp}
onChange={(event) => handleDisallowSignUpChange(event.target.checked)} onChange={(event) => handleDisallowSignUpChange(event.target.checked)}

View File

@ -35,6 +35,10 @@ export function getUserById(id: number) {
return axios.get<User>(`/api/v1/user/${id}`); return axios.get<User>(`/api/v1/user/${id}`);
} }
export function createUser(userCreate: UserCreate) {
return axios.post<User>("/api/v1/user", userCreate);
}
export function patchUser(userPatch: UserPatch) { export function patchUser(userPatch: UserPatch) {
return axios.patch<User>(`/api/v1/user/${userPatch.id}`, userPatch); return axios.patch<User>(`/api/v1/user/${userPatch.id}`, userPatch);
} }

View File

@ -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"; import Header from "../components/Header";
const Root: React.FC = () => { const Root: React.FC = () => {
const navigate = useNavigate();
const currentUser = useUserStore().getCurrentUser();
useEffect(() => {
if (!currentUser) {
navigate("/auth", {
replace: true,
});
}
}, []);
return ( return (
<div className="w-full h-full flex flex-col justify-start items-start"> <>
<Header /> {currentUser && (
<Outlet /> <div className="w-full h-full flex flex-col justify-start items-start">
</div> <Header />
<Outlet />
</div>
)}
</>
); );
}; };

View File

@ -1,14 +1,21 @@
import { CssVarsProvider } from "@mui/joy";
import { createRoot } from "react-dom/client"; import { createRoot } from "react-dom/client";
import { Toaster } from "react-hot-toast";
import { Provider } from "react-redux"; import { Provider } from "react-redux";
import { RouterProvider } from "react-router-dom";
import store from "./stores"; import store from "./stores";
import App from "./App"; import router from "./routers";
import "./i18n"; import "./i18n";
import "./css/index.css"; import "./css/index.css";
const container = document.getElementById("root"); const container = document.getElementById("root");
const root = createRoot(container as HTMLElement); const root = createRoot(container as HTMLElement);
root.render( root.render(
<Provider store={store}> <Provider store={store}>
<App /> <CssVarsProvider>
<RouterProvider router={router} />
<Toaster position="top-center" />
</CssVarsProvider>
</Provider> </Provider>
); );

View File

@ -1,21 +1,21 @@
import { Button } from "@mui/joy"; import { Button } from "@mui/joy";
import { useState } from "react"; import { useState } from "react";
import { useAppSelector } from "../stores"; import useUserStore from "../stores/v1/user";
import ChangePasswordDialog from "../components/ChangePasswordDialog"; import ChangePasswordDialog from "../components/ChangePasswordDialog";
import EditUserinfoDialog from "../components/EditUserinfoDialog"; import EditUserinfoDialog from "../components/EditUserinfoDialog";
const Account: React.FC = () => { const Account: React.FC = () => {
const user = useAppSelector((state) => state.user).user as User; const currentUser = useUserStore().getCurrentUser();
const [showEditUserinfoDialog, setShowEditUserinfoDialog] = useState<boolean>(false); const [showEditUserinfoDialog, setShowEditUserinfoDialog] = useState<boolean>(false);
const [showChangePasswordDialog, setShowChangePasswordDialog] = useState<boolean>(false); const [showChangePasswordDialog, setShowChangePasswordDialog] = useState<boolean>(false);
return ( return (
<> <>
<div className="mx-auto max-w-4xl w-full px-3 py-6 flex flex-col justify-start items-start space-y-4"> <div className="mx-auto max-w-4xl w-full px-3 py-6 flex flex-col justify-start items-start space-y-4">
<p className="text-3xl my-2">{user.nickname}</p> <p className="text-3xl my-2">{currentUser.nickname}</p>
<p className="leading-8 flex flex-row justify-start items-center"> <p className="leading-8 flex flex-row justify-start items-center">
<span className="mr-3 text-gray-500 font-mono">Email: </span> <span className="mr-3 text-gray-500 font-mono">Email: </span>
{user.email} {currentUser.email}
</p> </p>
<div className="flex flex-row justify-start items-center gap-2"> <div className="flex flex-row justify-start items-center gap-2">
<Button variant="outlined" color="neutral" onClick={() => setShowEditUserinfoDialog(true)}> <Button variant="outlined" color="neutral" onClick={() => setShowEditUserinfoDialog(true)}>

View File

@ -2,6 +2,7 @@ import { Button, Tab, TabList, Tabs } from "@mui/joy";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { shortcutService } from "../services"; import { shortcutService } from "../services";
import { useAppSelector } from "../stores"; import { useAppSelector } from "../stores";
import useUserStore from "../stores/v1/user";
import useLoading from "../hooks/useLoading"; import useLoading from "../hooks/useLoading";
import Icon from "../components/Icon"; import Icon from "../components/Icon";
import ShortcutListView from "../components/ShortcutListView"; import ShortcutListView from "../components/ShortcutListView";
@ -13,13 +14,14 @@ interface State {
const Home: React.FC = () => { const Home: React.FC = () => {
const loadingState = useLoading(); const loadingState = useLoading();
const currentUser = useUserStore().getCurrentUser();
const { shortcutList } = useAppSelector((state) => state.shortcut); const { shortcutList } = useAppSelector((state) => state.shortcut);
const user = useAppSelector((state) => state.user).user as User;
const [state, setState] = useState<State>({ const [state, setState] = useState<State>({
showCreateShortcutDialog: false, showCreateShortcutDialog: false,
}); });
const [selectedFilter, setSelectFilter] = useState<"ALL" | "PRIVATE">("ALL"); 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(() => { useEffect(() => {
Promise.all([shortcutService.getMyAllShortcuts()]).finally(() => { Promise.all([shortcutService.getMyAllShortcuts()]).finally(() => {

View File

@ -1,15 +1,21 @@
import { useAppSelector } from "../stores"; import useUserStore from "../stores/v1/user";
import AccountSection from "../components/setting/AccountSection"; import AccountSection from "../components/setting/AccountSection";
import WorkspaceSection from "../components/setting/WorkspaceSection"; import WorkspaceSection from "../components/setting/WorkspaceSection";
import UserSection from "../components/setting/UserSection";
const Setting: React.FC = () => { const Setting: React.FC = () => {
const user = useAppSelector((state) => state.user).user as User; const currentUser = useUserStore().getCurrentUser();
const isAdmin = user.role === "ADMIN"; const isAdmin = currentUser.role === "ADMIN";
return ( return (
<div className="mx-auto max-w-4xl w-full px-3 py-6 flex flex-col justify-start items-start space-y-4"> <div className="mx-auto max-w-4xl w-full px-3 py-6 flex flex-col justify-start items-start space-y-4">
<AccountSection /> <AccountSection />
{isAdmin && <WorkspaceSection />} {isAdmin && (
<>
<UserSection />
<WorkspaceSection />
</>
)}
</div> </div>
); );
}; };

View File

@ -3,12 +3,13 @@ import React, { useEffect, useState } from "react";
import { Link, useNavigate } from "react-router-dom"; import { Link, useNavigate } from "react-router-dom";
import { toast } from "react-hot-toast"; import { toast } from "react-hot-toast";
import * as api from "../helpers/api"; import * as api from "../helpers/api";
import { userService } from "../services";
import { useAppSelector } from "../stores"; import { useAppSelector } from "../stores";
import useLoading from "../hooks/useLoading"; import useLoading from "../hooks/useLoading";
import useUserStore from "../stores/v1/user";
const SignIn: React.FC = () => { const SignIn: React.FC = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const userStore = useUserStore();
const { const {
workspaceProfile: { disallowSignUp }, workspaceProfile: { disallowSignUp },
} = useAppSelector((state) => state.global); } = useAppSelector((state) => state.global);
@ -18,7 +19,7 @@ const SignIn: React.FC = () => {
const allowConfirm = email.length > 0 && password.length > 0; const allowConfirm = email.length > 0 && password.length > 0;
useEffect(() => { useEffect(() => {
userService.doSignOut(); api.signout();
}, []); }, []);
const handleEmailInputChanged = (e: React.ChangeEvent<HTMLInputElement>) => { const handleEmailInputChanged = (e: React.ChangeEvent<HTMLInputElement>) => {
@ -39,7 +40,7 @@ const SignIn: React.FC = () => {
try { try {
actionBtnLoadingState.setLoading(); actionBtnLoadingState.setLoading();
await api.signin(email, password); await api.signin(email, password);
const user = await userService.doSignIn(); const user = await userStore.fetchCurrentUser();
if (user) { if (user) {
navigate("/", { navigate("/", {
replace: true, replace: true,

View File

@ -3,11 +3,16 @@ import React, { useEffect, useState } from "react";
import { Link, useNavigate } from "react-router-dom"; import { Link, useNavigate } from "react-router-dom";
import { toast } from "react-hot-toast"; import { toast } from "react-hot-toast";
import * as api from "../helpers/api"; import * as api from "../helpers/api";
import { userService } from "../services"; import { globalService } from "../services";
import useLoading from "../hooks/useLoading"; import useLoading from "../hooks/useLoading";
import useUserStore from "../stores/v1/user";
const SignUp: React.FC = () => { const SignUp: React.FC = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const userStore = useUserStore();
const {
workspaceProfile: { disallowSignUp },
} = globalService.getState();
const [email, setEmail] = useState(""); const [email, setEmail] = useState("");
const [nickname, setNickname] = useState(""); const [nickname, setNickname] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
@ -15,7 +20,15 @@ const SignUp: React.FC = () => {
const allowConfirm = email.length > 0 && nickname.length > 0 && password.length > 0; const allowConfirm = email.length > 0 && nickname.length > 0 && password.length > 0;
useEffect(() => { useEffect(() => {
userService.doSignOut(); if (disallowSignUp) {
return navigate("/auth", {
replace: true,
});
}
}, []);
useEffect(() => {
api.signout();
}, []); }, []);
const handleEmailInputChanged = (e: React.ChangeEvent<HTMLInputElement>) => { const handleEmailInputChanged = (e: React.ChangeEvent<HTMLInputElement>) => {
@ -41,7 +54,7 @@ const SignUp: React.FC = () => {
try { try {
actionBtnLoadingState.setLoading(); actionBtnLoadingState.setLoading();
await api.signup(email, nickname, password); await api.signup(email, nickname, password);
const user = await userService.doSignIn(); const user = await userStore.fetchCurrentUser();
if (user) { if (user) {
navigate("/", { navigate("/", {
replace: true, replace: true,

View File

@ -1,83 +1,37 @@
import { createBrowserRouter, redirect } from "react-router-dom"; import { createBrowserRouter } from "react-router-dom";
import { isNullorUndefined } from "../helpers/utils";
import { globalService, userService } from "../services";
import Root from "../layouts/Root"; import Root from "../layouts/Root";
import SignIn from "../pages/SignIn"; import SignIn from "../pages/SignIn";
import SignUp from "../pages/SignUp"; import SignUp from "../pages/SignUp";
import Home from "../pages/Home"; import Home from "../pages/Home";
import Setting from "../pages/Setting"; import Setting from "../pages/Setting";
import App from "../App";
const router = createBrowserRouter([ const router = createBrowserRouter([
{
path: "/auth",
element: <SignIn />,
loader: async () => {
try {
await globalService.initialState();
} catch (error) {
// do nth
}
return null;
},
},
{
path: "/auth/signup",
element: <SignUp />,
loader: async () => {
try {
await globalService.initialState();
} catch (error) {
// do nth
}
const {
workspaceProfile: { disallowSignUp },
} = globalService.getState();
if (disallowSignUp) {
return redirect("/auth");
}
return null;
},
},
{ {
path: "/", path: "/",
element: <Root />, element: <App />,
children: [ children: [
{ {
path: "", path: "auth",
element: <Home />, element: <SignIn />,
loader: async () => {
try {
await userService.initialState();
} catch (error) {
// do nth
}
const { user } = userService.getState();
if (isNullorUndefined(user)) {
return redirect("/auth");
}
return null;
},
}, },
{ {
path: "/setting", path: "auth/signup",
element: <Setting />, element: <SignUp />,
loader: async () => { },
try { {
await userService.initialState(); path: "",
} catch (error) { element: <Root />,
// do nth children: [
} {
path: "",
const { user } = userService.getState(); element: <Home />,
if (isNullorUndefined(user)) { },
return redirect("/auth"); {
} path: "/setting",
return null; element: <Setting />,
}, },
],
}, },
], ],
}, },

View File

@ -1,7 +1,6 @@
import * as api from "../helpers/api"; import * as api from "../helpers/api";
import store from "../stores"; import store from "../stores";
import { setGlobalState } from "../stores/modules/global"; import { setGlobalState } from "../stores/modules/global";
import userService from "./userService";
const globalService = { const globalService = {
getState: () => { getState: () => {
@ -15,12 +14,6 @@ const globalService = {
} catch (error) { } catch (error) {
// do nth // do nth
} }
try {
await userService.initialState();
} catch (error) {
// do nth
}
}, },
}; };

View File

@ -1,5 +1,4 @@
import globalService from "./globalService"; import globalService from "./globalService";
import shortcutService from "./shortcutService"; import shortcutService from "./shortcutService";
import userService from "./userService";
export { globalService, shortcutService, userService }; export { globalService, shortcutService };

View File

@ -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<void> => {
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;

View File

@ -1,13 +1,11 @@
import { configureStore } from "@reduxjs/toolkit"; import { configureStore } from "@reduxjs/toolkit";
import { TypedUseSelectorHook, useSelector } from "react-redux"; import { TypedUseSelectorHook, useSelector } from "react-redux";
import globalReducer from "./modules/global"; import globalReducer from "./modules/global";
import userReducer from "./modules/user";
import shortcutReducer from "./modules/shortcut"; import shortcutReducer from "./modules/shortcut";
const store = configureStore({ const store = configureStore({
reducer: { reducer: {
global: globalReducer, global: globalReducer,
user: userReducer,
shortcut: shortcutReducer, shortcut: shortcutReducer,
}, },
}); });

View File

@ -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<User | undefined>) => {
return {
...state,
user: action.payload,
};
},
patchUser: (state, action: PayloadAction<Partial<User>>) => {
return {
...state,
user: {
...state.user,
...action.payload,
} as User,
};
},
},
});
export const { setUser, patchUser } = userSlice.actions;
export default userSlice.reducer;

83
web/src/stores/v1/user.ts Normal file
View File

@ -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<User[]>;
fetchCurrentUser: () => Promise<User>;
getOrFetchUserById: (id: UserId) => Promise<User>;
getUserById: (id: UserId) => User;
getCurrentUser: () => User;
createUser: (userCreate: UserCreate) => Promise<User>;
patchUser: (userPatch: UserPatch) => Promise<void>;
}
const useUserStore = create<UserState>()((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;

View File

@ -14,6 +14,13 @@ interface User {
role: Role; role: Role;
} }
interface UserCreate {
email: string;
nickname: string;
password: string;
role: Role;
}
interface UserPatch { interface UserPatch {
id: UserId; id: UserId;
@ -21,6 +28,7 @@ interface UserPatch {
email?: string; email?: string;
nickname?: string; nickname?: string;
password?: string; password?: string;
role?: Role;
} }
interface UserDelete { interface UserDelete {