diff --git a/server/router/api/v1/user_service.go b/server/router/api/v1/user_service.go index bb7727c4..3c70126c 100644 --- a/server/router/api/v1/user_service.go +++ b/server/router/api/v1/user_service.go @@ -145,11 +145,9 @@ func (s *APIV1Service) CreateUser(ctx context.Context, request *v1pb.CreateUserR } func (s *APIV1Service) UpdateUser(ctx context.Context, request *v1pb.UpdateUserRequest) (*v1pb.User, error) { - workspaceGeneralSetting, err := s.Store.GetWorkspaceGeneralSetting(ctx) - if err != nil { - return nil, status.Errorf(codes.Internal, "failed to get workspace general setting: %v", err) + if request.UpdateMask == nil || len(request.UpdateMask.Paths) == 0 { + return nil, status.Errorf(codes.InvalidArgument, "update mask is empty") } - userID, err := ExtractUserIDFromName(request.User.Name) if err != nil { return nil, status.Errorf(codes.InvalidArgument, "invalid user name: %v", err) @@ -158,12 +156,11 @@ func (s *APIV1Service) UpdateUser(ctx context.Context, request *v1pb.UpdateUserR if err != nil { return nil, status.Errorf(codes.Internal, "failed to get user: %v", err) } + // Check permission. + // Only allow admin or self to update user. if currentUser.ID != userID && currentUser.Role != store.RoleAdmin && currentUser.Role != store.RoleHost { return nil, status.Errorf(codes.PermissionDenied, "permission denied") } - if request.UpdateMask == nil || len(request.UpdateMask.Paths) == 0 { - return nil, status.Errorf(codes.InvalidArgument, "update mask is empty") - } user, err := s.Store.GetUser(ctx, &store.FindUser{ID: &userID}) if err != nil { @@ -178,6 +175,10 @@ func (s *APIV1Service) UpdateUser(ctx context.Context, request *v1pb.UpdateUserR ID: user.ID, UpdatedTs: ¤tTs, } + workspaceGeneralSetting, err := s.Store.GetWorkspaceGeneralSetting(ctx) + if err != nil { + return nil, status.Errorf(codes.Internal, "failed to get workspace general setting: %v", err) + } for _, field := range request.UpdateMask.Paths { if field == "username" { if workspaceGeneralSetting.DisallowChangeUsername { @@ -199,6 +200,10 @@ func (s *APIV1Service) UpdateUser(ctx context.Context, request *v1pb.UpdateUserR } else if field == "description" { update.Description = &request.User.Description } else if field == "role" { + // Only allow admin to update role. + if currentUser.Role != store.RoleAdmin && currentUser.Role != store.RoleHost { + return nil, status.Errorf(codes.PermissionDenied, "permission denied") + } role := convertUserRoleToStore(request.User.Role) update.Role = &role } else if field == "password" { diff --git a/store/db/mysql/user.go b/store/db/mysql/user.go index fab15066..0db9dda9 100644 --- a/store/db/mysql/user.go +++ b/store/db/mysql/user.go @@ -64,6 +64,9 @@ func (d *DB) UpdateUser(ctx context.Context, update *store.UpdateUser) (*store.U if v := update.Description; v != nil { set, args = append(set, "`description` = ?"), append(args, *v) } + if v := update.Role; v != nil { + set, args = append(set, "`role` = ?"), append(args, *v) + } args = append(args, update.ID) query := "UPDATE `user` SET " + strings.Join(set, ", ") + " WHERE `id` = ?" diff --git a/store/db/postgres/user.go b/store/db/postgres/user.go index 807b8955..87ea847c 100644 --- a/store/db/postgres/user.go +++ b/store/db/postgres/user.go @@ -51,6 +51,9 @@ func (d *DB) UpdateUser(ctx context.Context, update *store.UpdateUser) (*store.U if v := update.Description; v != nil { set, args = append(set, "description = "+placeholder(len(args)+1)), append(args, *v) } + if v := update.Role; v != nil { + set, args = append(set, "role = "+placeholder(len(args)+1)), append(args, *v) + } query := ` UPDATE "user" diff --git a/store/db/sqlite/user.go b/store/db/sqlite/user.go index c07ef4a6..1003107f 100644 --- a/store/db/sqlite/user.go +++ b/store/db/sqlite/user.go @@ -52,6 +52,9 @@ func (d *DB) UpdateUser(ctx context.Context, update *store.UpdateUser) (*store.U if v := update.Description; v != nil { set, args = append(set, "description = ?"), append(args, *v) } + if v := update.Role; v != nil { + set, args = append(set, "role = ?"), append(args, *v) + } args = append(args, update.ID) query := ` diff --git a/web/src/components/CreateUserDialog.tsx b/web/src/components/CreateUserDialog.tsx new file mode 100644 index 00000000..db27dd7e --- /dev/null +++ b/web/src/components/CreateUserDialog.tsx @@ -0,0 +1,134 @@ +import { Radio, RadioGroup } from "@mui/joy"; +import { Button, Input } from "@usememos/mui"; +import { XIcon } from "lucide-react"; +import { useState } from "react"; +import { toast } from "react-hot-toast"; +import { userServiceClient } from "@/grpcweb"; +import useLoading from "@/hooks/useLoading"; +import { User, User_Role } from "@/types/proto/api/v1/user_service"; +import { useTranslate } from "@/utils/i18n"; +import { generateDialog } from "./Dialog"; + +interface Props extends DialogProps { + user?: User; + confirmCallback?: () => void; +} + +const CreateUserDialog: React.FC = (props: Props) => { + const { confirmCallback, destroy } = props; + const t = useTranslate(); + const [user, setUser] = useState(User.fromPartial({ ...props.user })); + const requestState = useLoading(false); + const isCreating = !props.user; + + const setPartialUser = (state: Partial) => { + setUser({ + ...user, + ...state, + }); + }; + + const handleConfirm = async () => { + if (isCreating && (!user.username || !user.password)) { + toast.error("Username and password cannot be empty"); + return; + } + + try { + if (isCreating) { + await userServiceClient.createUser({ user }); + toast.success("Create user successfully"); + } else { + const updateMask = []; + if (user.username !== props.user?.username) { + updateMask.push("username"); + } + if (user.password) { + updateMask.push("password"); + } + if (user.role !== props.user?.role) { + updateMask.push("role"); + } + await userServiceClient.updateUser({ user, updateMask }); + toast.success("Update user successfully"); + } + } catch (error: any) { + console.error(error); + toast.error(error.details); + } + if (confirmCallback) { + confirmCallback(); + } + destroy(); + }; + + return ( +
+
+

{`${isCreating ? t("common.create") : t("common.edit")} ${t("common.user")}`}

+ +
+
+
+ {t("common.username")} + + setPartialUser({ + username: e.target.value, + }) + } + /> + {t("common.password")} + + setPartialUser({ + password: e.target.value, + }) + } + /> + {t("common.role")} + setPartialUser({ role: e.target.value as User_Role })} + > + + + +
+
+ + +
+
+
+ ); +}; + +function showCreateUserDialog(user?: User, confirmCallback?: () => void) { + generateDialog( + { + className: "create-user-dialog", + dialogName: "create-user-dialog", + }, + CreateUserDialog, + { user, confirmCallback }, + ); +} + +export default showCreateUserDialog; diff --git a/web/src/components/Settings/MemberSection.tsx b/web/src/components/Settings/MemberSection.tsx index 58155e9a..556775e4 100644 --- a/web/src/components/Settings/MemberSection.tsx +++ b/web/src/components/Settings/MemberSection.tsx @@ -10,7 +10,7 @@ import { userStore } from "@/store/v2"; import { State } from "@/types/proto/api/v1/common"; import { User, User_Role } from "@/types/proto/api/v1/user_service"; import { useTranslate } from "@/utils/i18n"; -import showChangeMemberPasswordDialog from "../ChangeMemberPasswordDialog"; +import showCreateUserDialog from "../CreateUserDialog"; interface LocalState { creatingUser: User; @@ -106,10 +106,6 @@ const MemberSection = () => { }); }; - const handleChangePasswordClick = (user: User) => { - showChangeMemberPasswordDialog(user); - }; - const handleArchiveUserClick = async (user: User) => { const confirmed = window.confirm(t("setting.member-section.archive-warning", { username: user.nickname })); if (confirmed) { @@ -222,9 +218,7 @@ const MemberSection = () => { - handleChangePasswordClick(user)}> - {t("setting.account-section.change-password")} - + showCreateUserDialog(user, () => fetchUsers())}>{t("common.update")} {user.state === State.NORMAL ? ( handleArchiveUserClick(user)}>{t("setting.member-section.archive-member")} ) : ( diff --git a/web/src/locales/en.json b/web/src/locales/en.json index 5569185b..c457ceca 100644 --- a/web/src/locales/en.json +++ b/web/src/locales/en.json @@ -94,6 +94,7 @@ "unpin": "Unpin", "update": "Update", "upload": "Upload", + "user": "User", "username": "Username", "version": "Version", "visibility": "Visibility",