diff --git a/client/app/components/QueryOwnerEditorDialog/index.jsx b/client/app/components/QueryOwnerEditorDialog/index.jsx new file mode 100644 index 0000000000..ca7d970d0c --- /dev/null +++ b/client/app/components/QueryOwnerEditorDialog/index.jsx @@ -0,0 +1,147 @@ +import React, { useState, useEffect, useCallback } from "react"; +import { axios } from "@/services/axios"; +import PropTypes from "prop-types"; +import { each, debounce, get, find } from "lodash"; +import Button from "antd/lib/button"; +import List from "antd/lib/list"; +import Modal from "antd/lib/modal"; +import Select from "antd/lib/select"; +import Tag from "antd/lib/tag"; +import Tooltip from "@/components/Tooltip"; +import { wrap as wrapDialog, DialogPropType } from "@/components/DialogWrapper"; +import { toHuman } from "@/lib/utils"; +import HelpTrigger from "@/components/HelpTrigger"; +import { UserPreviewCard } from "@/components/PreviewCard"; +import PlainButton from "@/components/PlainButton"; +import notification from "@/services/notification"; +import User from "@/services/user"; + +import "./index.less"; + +const { Option } = Select; +const DEBOUNCE_SEARCH_DURATION = 200; + +const searchUsers = searchTerm => + User.query({ q: searchTerm }) + .then(({ results }) => results) + .catch(() => []); + +function OwnerEditorDialogHeader({ context }) { + return ( + <> + Update Query Owner +
+ {`Updating the ${context} owner is enabled for the author of the query and for admins. `} +
+ + ); +} + +OwnerEditorDialogHeader.propTypes = { context: PropTypes.oneOf(["query"]) }; +OwnerEditorDialogHeader.defaultProps = { context: "query" }; + +function UserSelect({ onSelect, shouldShowUser }) { + const [loadingUsers, setLoadingUsers] = useState(true); + const [users, setUsers] = useState([]); + const [searchTerm, setSearchTerm] = useState(""); + + const debouncedSearchUsers = useCallback( + debounce( + search => + searchUsers(search) + .then(setUsers) + .finally(() => setLoadingUsers(false)), + DEBOUNCE_SEARCH_DURATION + ), + [] + ); + + useEffect(() => { + setLoadingUsers(true); + debouncedSearchUsers(searchTerm); + }, [debouncedSearchUsers, searchTerm]); + + return ( + + ); +} + +UserSelect.propTypes = { + onSelect: PropTypes.func, + shouldShowUser: PropTypes.func, +}; +UserSelect.defaultProps = { onSelect: () => {}, shouldShowUser: () => true }; + +function OwnerEditorDialog({ dialog, author, context }) { + + const [owner, setOwner] = useState(author); + + const loadOwner = useCallback((userId) => { + User.get({ id: userId }).then(user => setOwner(user)); + }, []); + + const userIsOwner = useCallback( + user => user.id === author.id, + [author.id] + ); + + // useEffect(() => { + // loadOwner(author); + // }, [author, loadOwner]); + + return ( + } + onOk={() => dialog.close(owner)}> + loadOwner(userId)} + shouldShowUser={user => !userIsOwner(user)} + /> +
+
Query Owner
+
+ + {owner.id === author.id ? ( + Current Owner + ) : ( New Owner )} + +
+ ); +} + +OwnerEditorDialog.propTypes = { + dialog: DialogPropType.isRequired, + author: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types + context: PropTypes.oneOf(["query"]), +}; + +OwnerEditorDialog.defaultProps = { context: "query" }; + +export default wrapDialog(OwnerEditorDialog); \ No newline at end of file diff --git a/client/app/components/QueryOwnerEditorDialog/index.less b/client/app/components/QueryOwnerEditorDialog/index.less new file mode 100644 index 0000000000..576236a385 --- /dev/null +++ b/client/app/components/QueryOwnerEditorDialog/index.less @@ -0,0 +1,8 @@ +.query-owner-editor-dialog { + .ant-select-dropdown-menu-item-disabled { + // make sure .text-muted has the disabled color + &, .text-muted { + color: rgba(0, 0, 0, 0.25); + } + } +} \ No newline at end of file diff --git a/client/app/pages/queries-list/QueriesList.jsx b/client/app/pages/queries-list/QueriesList.jsx index f49358d457..83fc119088 100644 --- a/client/app/pages/queries-list/QueriesList.jsx +++ b/client/app/pages/queries-list/QueriesList.jsx @@ -72,7 +72,7 @@ const listColumns = [ width: null, } ), - Columns.custom((text, item) => item.user.name, { title: "Created By", width: "1%" }), + Columns.custom((text, item) => item.user.name, { title: "Owner", width: "1%" }), Columns.dateTime.sortable({ title: "Created At", field: "created_at", width: "1%" }), Columns.dateTime.sortable({ title: "Last Executed At", diff --git a/client/app/pages/queries/components/QueryPageHeader.jsx b/client/app/pages/queries/components/QueryPageHeader.jsx index b788409e08..7b963b73c1 100644 --- a/client/app/pages/queries/components/QueryPageHeader.jsx +++ b/client/app/pages/queries/components/QueryPageHeader.jsx @@ -21,6 +21,7 @@ import useRenameQuery from "../hooks/useRenameQuery"; import useDuplicateQuery from "../hooks/useDuplicateQuery"; import useApiKeyDialog from "../hooks/useApiKeyDialog"; import usePermissionsEditorDialog from "../hooks/usePermissionsEditorDialog"; +import useQueryOwnerEditorDialog from "../hooks/useQueryOwnerEditorDialog"; import "./QueryPageHeader.less"; @@ -81,6 +82,7 @@ export default function QueryPageHeader({ const [isDuplicating, duplicateQuery] = useDuplicateQuery(query); const openApiKeyDialog = useApiKeyDialog(query, onChange); const openPermissionsEditorDialog = usePermissionsEditorDialog(query); + const openQueryOwnerEditorDialog = useQueryOwnerEditorDialog(query, onChange); const moreActionsMenu = useMemo( () => @@ -109,6 +111,12 @@ export default function QueryPageHeader({ title: "Manage Permissions", onClick: openPermissionsEditorDialog, }, + updateQueryOwner: { + isAvailable: + !queryFlags.isNew && queryFlags.canEdit && !queryFlags.isArchived && clientConfig.showPermissionsControl, + title: "Update Query Owner", + onClick: openQueryOwnerEditorDialog, + }, publish: { isAvailable: !isDesktop && queryFlags.isDraft && !queryFlags.isArchived && !queryFlags.isNew && queryFlags.canEdit, @@ -143,6 +151,7 @@ export default function QueryPageHeader({ publishQuery, unpublishQuery, openApiKeyDialog, + openQueryOwnerEditorDialog, ] ); diff --git a/client/app/pages/queries/hooks/useQueryOwnerEditorDialog.js b/client/app/pages/queries/hooks/useQueryOwnerEditorDialog.js new file mode 100644 index 0000000000..f1771f1866 --- /dev/null +++ b/client/app/pages/queries/hooks/useQueryOwnerEditorDialog.js @@ -0,0 +1,19 @@ +import { useCallback } from "react"; +import QueryOwnerEditorDialog from "@/components/QueryOwnerEditorDialog"; +import useUpdateQuery from "./useUpdateQuery"; +import recordEvent from "@/services/recordEvent"; + +export default function useQueryOwnerEditorDialog(query, onChange) { + + const updateQuery = useUpdateQuery(query, onChange); + + return useCallback(() => { + QueryOwnerEditorDialog.showModal({ + context: "query", + author: query.user, + }).onClose(user => { + recordEvent("edit_query_owner", "query", query.id); + updateQuery({ user: user }); + }); + }, [query.id, query.user, updateQuery]); +} \ No newline at end of file diff --git a/client/app/pages/queries/hooks/useUpdateQuery.jsx b/client/app/pages/queries/hooks/useUpdateQuery.jsx index 8c70f0564e..26eb9ff4d8 100644 --- a/client/app/pages/queries/hooks/useUpdateQuery.jsx +++ b/client/app/pages/queries/hooks/useUpdateQuery.jsx @@ -96,6 +96,7 @@ export default function useUpdateQuery(query, onChange) { "latest_query_data_id", "is_draft", "tags", + "user" ]); } diff --git a/redash/handlers/queries.py b/redash/handlers/queries.py index 71ae418da8..b277c2cbc7 100644 --- a/redash/handlers/queries.py +++ b/redash/handlers/queries.py @@ -325,6 +325,7 @@ def post(self, query_id): """ query = get_object_or_404(models.Query.get_by_id_and_org, query_id, self.current_org) query_def = request.get_json(force=True) + previous_query_user = query.user require_object_modify_permission(query, self.current_user) require_access_to_dropdown_queries(self.current_user, query_def) @@ -335,7 +336,6 @@ def post(self, query_id): "api_key", "visualizations", "latest_query_data", - "user", "last_modified_by", "org", ]: @@ -351,6 +351,17 @@ def post(self, query_id): data_source = models.DataSource.get_by_id_and_org(query_def["data_source_id"], self.current_org) require_access(data_source, self.current_user, not_view_only) + if "user" in query_def: + require_admin_or_owner(self.current_user.id) + new_user = models.User.get_by_id(query_def["user"]["id"]) + if "data_source_id" in query_def: + data_source = models.DataSource.get_by_id_and_org(query_def["data_source_id"], self.current_org) + require_access(data_source, new_user, not_view_only) + else: + data_source = query.data_source + require_access(data_source, new_user, not_view_only) + query_def["user"] = new_user + query_def["last_modified_by"] = self.current_user query_def["changed_by"] = self.current_user # SQLAlchemy handles the case where a concurrent transaction beats us @@ -361,6 +372,9 @@ def post(self, query_id): try: self.update_model(query, query_def) + if "user" in query_def and query_def["user"] != previous_query_user: + models.AccessPermission.revoke(query, query_def["user"], "modify") + models.AccessPermission.grant(query, "modify", previous_query_user, self.current_user) models.db.session.commit() except StaleDataError: abort(409)