diff --git a/static/app/router/routes.tsx b/static/app/router/routes.tsx index 2dacb634e849..ac5b29a87db4 100644 --- a/static/app/router/routes.tsx +++ b/static/app/router/routes.tsx @@ -2599,6 +2599,7 @@ function buildRoutes(): RouteObject[] { }, { path: 'users/', + name: t('Users'), children: [ { index: true, @@ -2606,6 +2607,7 @@ function buildRoutes(): RouteObject[] { }, { path: ':id', + name: t('Details'), component: make(() => import('sentry/views/admin/adminUserEdit')), }, ], diff --git a/static/app/views/admin/adminUserEdit.tsx b/static/app/views/admin/adminUserEdit.tsx index f026f7947910..641d1b3a297b 100644 --- a/static/app/views/admin/adminUserEdit.tsx +++ b/static/app/views/admin/adminUserEdit.tsx @@ -1,83 +1,58 @@ import {Fragment, useState} from 'react'; -import {useTheme} from '@emotion/react'; -import styled from '@emotion/styled'; -import {useMutation, useQueryClient} from '@tanstack/react-query'; +import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'; +import {z} from 'zod'; +import {Alert} from '@sentry/scraps/alert'; import {Button} from '@sentry/scraps/button'; +import {defaultFormOptions, useScrapsForm} from '@sentry/scraps/form'; +import {Flex} from '@sentry/scraps/layout'; import {useModal} from '@sentry/scraps/modal'; import {addErrorMessage, addSuccessMessage} from 'sentry/actionCreators/indicator'; import type {ModalRenderProps} from 'sentry/actionCreators/modal'; import {RadioGroup} from 'sentry/components/forms/controls/radioGroup'; -import {Form} from 'sentry/components/forms/form'; -import JsonForm from 'sentry/components/forms/jsonForm'; -import {FormModel} from 'sentry/components/forms/model'; -import type {JsonFormObject} from 'sentry/components/forms/types'; import {LoadingError} from 'sentry/components/loadingError'; import {LoadingIndicator} from 'sentry/components/loadingIndicator'; import {t} from 'sentry/locale'; import type {User} from 'sentry/types/user'; +import type {ApiResponse} from 'sentry/utils/api/apiFetch'; +import {apiOptions} from 'sentry/utils/api/apiOptions'; import {getApiUrl} from 'sentry/utils/api/getApiUrl'; -import {setApiQueryData, useApiQuery} from 'sentry/utils/queryClient'; -import {useApi} from 'sentry/utils/useApi'; +import {fetchMutation} from 'sentry/utils/queryClient'; +import {RequestError} from 'sentry/utils/requestError/requestError'; import {useNavigate} from 'sentry/utils/useNavigate'; import {useParams} from 'sentry/utils/useParams'; +import {BreadcrumbTitle} from 'sentry/views/settings/components/settingsBreadcrumb/breadcrumbTitle'; -const userEditForm: JsonFormObject = { - title: 'User details', - fields: [ - { - name: 'name', - type: 'string', - required: true, - label: t('Name'), - }, - { - name: 'username', - type: 'string', - required: true, - label: t('Username'), - help: t('The username is the unique id of the user in the system'), - }, - { - name: 'email', - type: 'string', - required: true, - label: t('Email'), - help: t('The users primary email address'), - }, - { - name: 'isActive', - type: 'boolean', - required: false, - label: t('Active'), - help: t( - 'Designates whether this user should be treated as active. Unselect this instead of deleting accounts.' - ), - }, - { - name: 'isStaff', - type: 'boolean', - required: false, - label: t('Admin'), - help: t('Designates whether this user can perform administrative functions.'), - }, - { - name: 'isSuperuser', - type: 'boolean', - required: false, - label: t('Superuser'), - help: t( - 'Designates whether this user has all permissions without explicitly assigning them.' - ), - }, - ], -}; +function userDetailsQueryOptions(userId: string) { + return apiOptions.as()('/users/$userId/', { + path: {userId}, + staleTime: 0, + }); +} -const REMOVE_BUTTON_LABEL = { - disable: t('Disable User'), - delete: t('Permanently Delete User'), -}; +const schema = z.object({ + name: z.string().trim().min(1, t('Name is required')), + username: z.string().trim().min(1, t('Username is required')), + email: z.string().email(t('A valid email is required')), + isActive: z.boolean(), + isStaff: z.boolean(), + isSuperuser: z.boolean(), +}); + +// The `/users/$userId/` PUT endpoint accepts these editable fields. +type UserUpdatePayload = z.infer; + +function toFormValues(user: User): UserUpdatePayload { + return { + name: user.name, + username: user.username, + email: user.email, + isActive: user.isActive, + isStaff: user.isStaff, + isSuperuser: user.isSuperuser, + }; +} type DeleteType = 'disable' | 'delete'; @@ -86,7 +61,7 @@ type RemoveModalProps = ModalRenderProps & { user: User; }; -function RemoveUserModal({user, onRemove, closeModal}: RemoveModalProps) { +function RemoveUserModal({user, onRemove, closeModal, Footer}: RemoveModalProps) { const [deleteType, setDeleteType] = useState('disable'); const handleRemove = () => { @@ -105,122 +80,237 @@ function RemoveUserModal({user, onRemove, closeModal}: RemoveModalProps) { ['delete', t('Permanently remove the user and their data.')], ]} /> - - - - +
+ + + + +
); } -function AdminUserEdit() { +function AdminUserEditForm({ + user, + userEndpoint, +}: { + user: User; + userEndpoint: ReturnType; +}) { const {openModal} = useModal(); - - const theme = useTheme(); - const {id} = useParams<{id: string}>(); - const userEndpoint = getApiUrl('/users/$userId/', {path: {userId: id}}); - const [formModel] = useState(() => new FormModel()); - const api = useApi({persistInFlight: true}); const navigate = useNavigate(); const queryClient = useQueryClient(); - const { - data: user, - isPending, - isError, - refetch, - } = useApiQuery([userEndpoint], { - staleTime: 0, + const updateMutation = useMutation({ + mutationFn: (data: UserUpdatePayload) => + fetchMutation({url: userEndpoint, method: 'PUT', data}), + onSuccess: response => { + queryClient.setQueryData(userDetailsQueryOptions(user.id).queryKey, prev => ({ + json: response, + headers: prev?.headers ?? {}, + })); + form.reset(toFormValues(response)); + addSuccessMessage(t('User account updated.')); + }, + onError: error => { + const detail = + error instanceof RequestError ? error.responseJSON?.detail : undefined; + addErrorMessage( + typeof detail === 'string' ? detail : t('Failed to update user account.') + ); + }, }); const deleteMutation = useMutation({ - mutationFn: () => { - return api.requestPromise(userEndpoint, { + mutationFn: () => + fetchMutation({ + url: userEndpoint, method: 'DELETE', data: {hardDelete: true, organizations: []}, - }); - }, + }), onSuccess: () => { - addSuccessMessage(t("%s's account has been deleted.", user?.email)); + addSuccessMessage(t("%s's account has been deleted.", user.email)); navigate('/manage/users/', {replace: true}); }, + onError: () => { + addErrorMessage(t('Failed to delete user account.')); + }, }); const deactivateMutation = useMutation({ - mutationFn: () => { - return api.requestPromise(userEndpoint, { + mutationFn: () => + fetchMutation({ + url: userEndpoint, method: 'PUT', data: {isActive: false}, - }); - }, + }), onSuccess: response => { - setApiQueryData(queryClient, [userEndpoint], response); - formModel.setInitialData(response); + queryClient.setQueryData(userDetailsQueryOptions(user.id).queryKey, prev => ({ + json: response, + headers: prev?.headers ?? {}, + })); + form.reset(toFormValues(response)); addSuccessMessage(t("%s's account has been deactivated.", response.email)); }, + onError: () => { + addErrorMessage(t('Failed to deactivate user account.')); + }, }); - const removeUser = (actionType: DeleteType) => - actionType === 'delete' ? deleteMutation.mutate() : deactivateMutation.mutate(); + const form = useScrapsForm({ + ...defaultFormOptions, + defaultValues: toFormValues(user), + validators: {onDynamic: schema}, + onSubmit: ({value}) => updateMutation.mutateAsync(value).catch(() => {}), + }); - if (isPending) { - return ; - } + return ( + + + + + {field => ( + + + + )} + - if (isError) { - return ; - } + + {field => ( + + + + )} + - if (!user) { - return null; - } + + {field => ( + + + + )} + - const openDeleteModal = () => - openModal(opts => ); + + {field => ( + + + + )} + - return ( - -

{t('Users')}

-

{t('Editing user: %s', user.email)}

-
{ - addErrorMessage(err?.responseJSON?.detail); - }} - onSubmitSuccess={response => { - setApiQueryData(queryClient, [userEndpoint], response); - addSuccessMessage(t('User account updated.')); - }} - extraButton={ + + {field => ( + + + + )} + + + + {field => ( + + + + )} + + + + - } - > - - + {t('Save Changes')} +
+
); } -const ModalFooter = styled('div')` - display: grid; - grid-auto-flow: column; - gap: ${p => p.theme.space.md}; - justify-content: end; - padding: 20px 30px; - margin: 20px -30px -30px; - border-top: 1px solid ${p => p.theme.tokens.border.primary}; -`; +function AdminUserEdit() { + const {id} = useParams<{id: string}>(); + const userEndpoint = getApiUrl('/users/$userId/', {path: {userId: id}}); + const queryClient = useQueryClient(); + + const { + data: user, + isLoading, + isPlaceholderData, + isError, + error, + refetch, + } = useQuery({ + ...userDetailsQueryOptions(id), + placeholderData: () => { + const found = queryClient + .getQueriesData>({queryKey: [getApiUrl('/users/')]}) + .flatMap(([, data]) => data?.json ?? []) + .find(candidate => candidate.id === id); + return found ? {json: found, headers: {}} : undefined; + }, + }); + + const notFound = error instanceof RequestError && error.status === 404; + + return ( + + + {isLoading || isPlaceholderData ? ( + + ) : isError && !notFound ? ( + + ) : notFound || !user ? ( + + + {t('The user you were looking for was not found.')} + + + ) : ( + + )} + + ); +} export default AdminUserEdit; diff --git a/static/app/views/admin/adminUsers.tsx b/static/app/views/admin/adminUsers.tsx index 23df83ae7584..65eda165970a 100644 --- a/static/app/views/admin/adminUsers.tsx +++ b/static/app/views/admin/adminUsers.tsx @@ -1,67 +1,140 @@ +import {Fragment} from 'react'; +import styled from '@emotion/styled'; +import {useQuery} from '@tanstack/react-query'; import moment from 'moment-timezone'; +import {debounce, parseAsString, parseAsStringLiteral, useQueryStates} from 'nuqs'; +import {CompactSelect} from '@sentry/scraps/compactSelect'; +import {Container, Flex, Stack} from '@sentry/scraps/layout'; import {Link} from '@sentry/scraps/link'; +import {OverlayTrigger} from '@sentry/scraps/overlayTrigger'; +import {Pagination} from '@sentry/scraps/pagination'; +import {Text} from '@sentry/scraps/text'; -import {ResultGrid} from 'sentry/components/resultGrid'; +import {LoadingError} from 'sentry/components/loadingError'; +import {LoadingIndicator} from 'sentry/components/loadingIndicator'; +import {SearchBar} from 'sentry/components/searchBar'; +import {SimpleTable} from 'sentry/components/tables/simpleTable'; +import {DEFAULT_DEBOUNCE_DURATION} from 'sentry/constants'; import {t} from 'sentry/locale'; +import type {User} from 'sentry/types/user'; +import {apiOptions, selectJsonWithHeaders} from 'sentry/utils/api/apiOptions'; -type Row = { - dateJoined: string; - email: string; - id: string; - lastLogin: string; - username: string; -}; +type Status = 'active' | 'disabled'; -const getRow = (row: Row) => [ - - - {row.username} - -
- {row.email !== row.username && {row.email}} - , - - {moment(row.dateJoined).format('ll')} - , - - {moment(row.lastLogin).format('ll')} - , +const STATUS_OPTIONS: Array<{label: string; value: Status}> = [ + {value: 'active', label: t('Active')}, + {value: 'disabled', label: t('Disabled')}, ]; +const STATUS_VALUES = STATUS_OPTIONS.map(option => option.value); + export default function AdminUsers() { - const columns = [ - User, - - Joined - , - - Last Login - , - ]; + const [{query, status, cursor}, setSearchParams] = useQueryStates({ + query: parseAsString.withDefault(''), + status: parseAsStringLiteral(STATUS_VALUES), + cursor: parseAsString, + }); + + const {data, isPending, isError, refetch} = useQuery({ + ...apiOptions.as()('/users/', { + query: { + query, + status: status ?? undefined, + sortBy: 'date', + cursor: cursor ?? undefined, + per_page: 50, + }, + staleTime: 0, + }), + select: selectJsonWithHeaders, + }); + + const users = data?.json; + const pageLinks = data?.headers.Link; + + const onSearch = (searchQuery: string) => + setSearchParams( + {query: searchQuery, cursor: null}, + {limitUrlUpdates: debounce(DEFAULT_DEBOUNCE_DURATION), history: 'replace'} + ); return ( -
-

{t('Users')}

- -
+ + + + {containerProps => ( + + )} + + ( + + {STATUS_OPTIONS.find(option => option.value === status)?.label ?? t('Any')} + + )} + value={status ?? undefined} + options={STATUS_OPTIONS} + onChange={option => + setSearchParams( + {status: option?.value ?? null, cursor: null}, + {history: 'push'} + ) + } + /> + + + {isError ? ( + + ) : isPending ? ( + + ) : ( + + + {t('User')} + {t('Joined')} + {t('Last Login')} + + {users?.length ? ( + users.map(user => ( + + + + + {user.username} + + {user.email !== user.username && ( + + {user.email} + + )} + + + + {moment(user.dateJoined).format('ll')} + + + {moment(user.lastLogin).format('ll')} + + + )) + ) : ( + {t('No users found.')} + )} + + )} + + {pageLinks && } + ); } + +const UsersTable = styled(SimpleTable)` + grid-template-columns: minmax(0, 1fr) max-content max-content; +`;