diff --git a/catalog/app/containers/Admin/Users/Users.js b/catalog/app/containers/Admin/Users/Users.js deleted file mode 100644 index fc39586cae4..00000000000 --- a/catalog/app/containers/Admin/Users/Users.js +++ /dev/null @@ -1,753 +0,0 @@ -import cx from 'classnames' -import * as FF from 'final-form' -import * as R from 'ramda' -import * as React from 'react' -import * as RF from 'react-final-form' -import * as M from '@material-ui/core' - -import * as Pagination from 'components/Pagination' -import * as Notifications from 'containers/Notifications' -import * as APIConnector from 'utils/APIConnector' -import * as Dialogs from 'utils/Dialogs' -import { useQueryS } from 'utils/GraphQL' -import * as Cache from 'utils/ResourceCache' -import * as Format from 'utils/format' -import * as validators from 'utils/validators' - -import * as Form from '../Form' -import * as Table from '../Table' -import * as data from '../data' - -import ROLES_QUERY from './gql/Roles.generated' - -const useMonoStyles = M.makeStyles((t) => ({ - root: { - fontFamily: t.typography.monospace.fontFamily, - }, -})) - -function Mono({ className, children }) { - const classes = useMonoStyles() - return {children} -} - -const useInviteStyles = M.makeStyles({ - infoIcon: { - fontSize: '1.25em', - verticalAlign: '-3px', - }, -}) - -// close: PT.func.isRequired, -// roles: PT.array.isRequired, -function Invite({ close, roles, defaultRoleId }) { - const classes = useInviteStyles() - const req = APIConnector.use() - const cache = Cache.use() - const { push } = Notifications.use() - const onSubmit = React.useCallback( - async ({ username, email, roleId }) => { - const role = roles.find((r) => r.id === roleId) - - try { - await req({ - endpoint: '/users/create', - method: 'POST', - body: JSON.stringify({ username, email }), - }) - - const user = { - dateJoined: new Date(), - email, - isActive: true, - isAdmin: false, - lastLogin: new Date(), - username, - } - - try { - await req({ - method: 'POST', - endpoint: '/users/set_role', - body: JSON.stringify({ username, role: role.name }), - }) - user.roleId = role.id - } catch (e) { - // eslint-disable-next-line no-console - console.error('Error setting role', { username, role }) - // eslint-disable-next-line no-console - console.dir(e) - } - - cache.patchOk(data.UsersResource, null, R.append(user)) - push('User invited') - close() - } catch (e) { - if (APIConnector.HTTPError.is(e, 400, /subscription is invalid/)) { - return { [FF.FORM_ERROR]: 'subscriptionInvalid' } - } - - if (APIConnector.HTTPError.is(e, 400, /Username is invalid/)) { - return { - username: 'invalid', - } - } - if (APIConnector.HTTPError.is(e, 409, /Username already taken/)) { - return { - username: 'taken', - } - } - if (APIConnector.HTTPError.is(e, 400, /Invalid email/)) { - return { - email: 'invalid', - } - } - if (APIConnector.HTTPError.is(e, 409, /Email already taken/)) { - return { - email: 'taken', - } - } - if (APIConnector.HTTPError.is(e, 500, /SMTP.*invalid/)) { - return { - [FF.FORM_ERROR]: 'smtp', - } - } - // eslint-disable-next-line no-console - console.error('Error creating user') - // eslint-disable-next-line no-console - console.dir(e) - return { - [FF.FORM_ERROR]: 'unexpected', - } - } - }, - [req, cache, push, close, roles], - ) - - return ( - - {({ - handleSubmit, - submitting, - submitError, - submitFailed, - error, - hasSubmitErrors, - hasValidationErrors, - modifiedSinceLastSubmit, - }) => ( - <> - Invite a user - -
- - Enter a valid username{' '} - - info - - - ), - }} - autoComplete="off" - /> - - - {roles.map((r) => ( - - {r.name} - - ))} - - {(!!error || !!submitError) && ( - - )} - - -
- - close('cancel')} - color="primary" - disabled={submitting} - > - Cancel - - - Invite - - - - )} -
- ) -} - -// close: PT.func.isRequired, -// user: PT.object.isRequired, -function Edit({ close, user: { email: oldEmail, username } }) { - const req = APIConnector.use() - const cache = Cache.use() - const { push } = Notifications.use() - - const onSubmit = React.useCallback( - async ({ email }) => { - if (email === oldEmail) { - close() - return - } - - try { - await req({ - endpoint: '/users/edit_email', - method: 'POST', - body: JSON.stringify({ username, email }), - }) - - cache.patchOk( - data.UsersResource, - null, - R.map((u) => (u.username === username ? { ...u, email } : u)), - ) - push('Changes saved') - close() - } catch (e) { - if (APIConnector.HTTPError.is(e, 400, /Another user already has that email/)) { - return { - email: 'taken', - } - } - if (APIConnector.HTTPError.is(e, 400, /Invalid email/)) { - return { - email: 'invalid', - } - } - // eslint-disable-next-line no-console - console.error('Error changing email') - // eslint-disable-next-line no-console - console.dir(e) - return { - [FF.FORM_ERROR]: 'unexpected', - } - } - }, - [close, username, oldEmail, req, cache, push], - ) - - return ( - - {({ - handleSubmit, - submitting, - submitFailed, - error, - hasSubmitErrors, - hasValidationErrors, - modifiedSinceLastSubmit, - }) => ( - <> - Edit user: "{username}" - -
- - {submitFailed && ( - - )} - - -
- - close('cancel')} - color="primary" - disabled={submitting} - > - Cancel - - - Save - - - - )} -
- ) -} - -// user: PT.object.isRequired, -// close: PT.func.isRequired, -function Delete({ user, close }) { - const req = APIConnector.use() - const cache = Cache.use() - const { push } = Notifications.use() - const doDelete = React.useCallback(() => { - close() - req({ - endpoint: '/users/delete', - method: 'POST', - body: JSON.stringify({ username: user.username }), - }) - .then(() => { - push(`User "${user.username}" deleted`) - }) - .catch((e) => { - // TODO: handle errors once the endpoint is working - cache.patchOk(data.UsersResource, null, R.append(user)) - push(`Error deleting user "${user.username}"`) - // eslint-disable-next-line no-console - console.error('Error deleting user') - // eslint-disable-next-line no-console - console.dir(e) - }) - // optimistically remove the user from cache - cache.patchOk(data.UsersResource, null, R.reject(R.propEq('username', user.username))) - }, [user, close, req, cache, push]) - - return ( - <> - Delete a user - - You are about to delete user "{user.username}". This operation is - irreversible. - - - close('cancel')} color="primary"> - Cancel - - - Delete - - - - ) -} - -// admin: PT.bool.isRequired, -// username: PT.string.isRequired, -// close: PT.func.isRequired, -function AdminRights({ username, admin, close }) { - const req = APIConnector.use() - const cache = Cache.use() - const { push } = Notifications.use() - const doChange = React.useCallback( - () => - close( - req({ - method: 'POST', - endpoint: `/users/${admin ? 'grant' : 'revoke'}_admin`, - body: JSON.stringify({ username }), - }) - .then(() => { - cache.patchOk( - data.UsersResource, - null, - R.map((u) => (u.username === username ? { ...u, isAdmin: admin } : u)), - ) - return 'ok' - }) - .catch((e) => { - push( - `Error ${admin ? 'granting' : 'revoking'} admin status for "${username}"`, - ) - // eslint-disable-next-line no-console - console.error('Error changing user admin status', { username, admin }) - // eslint-disable-next-line no-console - console.dir(e) - throw e - }), - ), - [admin, close, username, req, cache, push], - ) - - return ( - <> - {admin ? 'Grant' : 'Revoke'} admin rights - - You are about to {admin ? 'grant admin rights to' : 'revoke admin rights from'}{' '} - "{username}". - - - close('cancel')} color="primary"> - Cancel - - - {admin ? 'Grant' : 'Revoke'} - - - - ) -} - -const useUsernameStyles = M.makeStyles((t) => ({ - root: { - alignItems: 'center', - display: 'flex', - }, - admin: { - fontWeight: 600, - }, - icon: { - fontSize: '1em', - marginLeft: `calc(-1em - ${t.spacing(0.5)}px)`, - marginRight: t.spacing(0.5), - }, -})) - -// admin: PT.bool, -function Username({ className, admin = false, children, ...props }) { - const classes = useUsernameStyles() - return ( - - {admin && security} - {children} - - ) -} - -function Editable({ value, onChange, children }) { - const [busy, setBusy] = React.useState(false) - const [savedValue, saveValue] = React.useState(value) - const change = React.useCallback( - (newValue) => { - if (savedValue === newValue) return - if (busy) return - setBusy(true) - saveValue(newValue) - Promise.resolve(onChange(newValue)) - .then(() => { - setBusy(false) - }) - .catch((e) => { - saveValue(savedValue) - setBusy(false) - throw e - }) - }, - [onChange, busy, setBusy, savedValue, saveValue], - ) - - return children({ change, busy, value: savedValue }) -} - -// not a valid role name -const emptyRole = '' - -function UsersSkeleton() { - return ( - - - - - ) -} - -// users: PT.object.isRequired, -export default function Users({ users }) { - const rows = Cache.suspend(users) - const { roles, defaultRole } = useQueryS(ROLES_QUERY) - const defaultRoleId = defaultRole?.id - - const req = APIConnector.use() - const cache = Cache.use() - const { push } = Notifications.use() - const dialogs = Dialogs.use() - const { open: openDialog } = dialogs - - const setRole = React.useCallback( - (username, role) => - req({ - method: 'POST', - endpoint: '/users/set_role', - body: JSON.stringify({ username, role }), - }) - .then(() => { - cache.patchOk( - data.UsersResource, - null, - R.map((u) => (u.username === username ? { ...u, role } : u)), - ) - }) - .catch((e) => { - push(`Error changing role for "${username}"`) - // eslint-disable-next-line no-console - console.error('Error chaging role', { username, role }) - // eslint-disable-next-line no-console - console.dir(e) - throw e - }), - [req, cache, push], - ) - - const setIsActive = React.useCallback( - (username, active) => - req({ - method: 'POST', - endpoint: `/users/${active ? 'enable' : 'disable'}`, - body: JSON.stringify({ username }), - }) - .then(() => { - cache.patchOk( - data.UsersResource, - null, - R.map((u) => (u.username === username ? { ...u, isActive: active } : u)), - ) - }) - .catch((e) => { - push(`Error ${active ? 'enabling' : 'disabling'} "${username}"`) - // eslint-disable-next-line no-console - console.error('Error (de)activating user', { username, active }) - // eslint-disable-next-line no-console - console.dir(e) - throw e - }), - [req, cache, push], - ) - - const columns = React.useMemo( - () => [ - { - id: 'isActive', - label: 'Enabled', - getValue: R.prop('isActive'), - getDisplay: (v, u) => ( - setIsActive(u.username, active)}> - {({ change, busy, value }) => ( - change(e.target.checked)} - disabled={busy} - color="default" - /> - )} - - ), - }, - { - id: 'username', - label: 'Username', - getValue: R.prop('username'), - getDisplay: (v, u) => {v}, - props: { component: 'th', scope: 'row' }, - }, - { - id: 'email', - label: 'Email', - getValue: R.prop('email'), - }, - { - id: 'role', - label: 'Role', - getValue: (u) => u.roleId && (roles.find((r) => r.id === u.roleId) || {}).name, - getDisplay: (v, u) => ( - setRole(u.username, role)}> - {({ change, busy, value }) => ( - change(e.target.value)} - disabled={busy} - renderValue={R.identity} - > - {roles.map((r) => ( - - {r.name} - - ))} - - )} - - ), - }, - { - id: 'dateJoined', - label: 'Date joined', - getValue: R.prop('dateJoined'), - getDisplay: (v) => ( - - - - ), - }, - { - id: 'lastLogin', - label: 'Last login', - getValue: R.prop('lastLogin'), - getDisplay: (v) => ( - - - - ), - }, - { - id: 'isAdmin', - label: 'Admin', - hint: 'Admins can see this page, add/remove users, and make/remove admins', - getValue: R.prop('isAdmin'), - getDisplay: (v, u) => ( - { - const res = await openDialog(({ close }) => ( - - )) - if (res !== 'ok') throw new Error('cancelled') - }} - > - {({ change, busy, value }) => ( - change(e.target.checked)} - disabled={busy} - color="default" - /> - )} - - ), - }, - ], - [roles, openDialog, setIsActive, setRole], - ) - - const filtering = Table.useFiltering({ - rows, - filterBy: ({ email, username }) => email + username, - }) - const ordering = Table.useOrdering({ - rows: filtering.filtered, - column: columns[0], - }) - const pagination = Pagination.use(ordering.ordered, { - getItemId: R.prop('username'), - }) - - const toolbarActions = [ - { - title: 'Invite', - icon: add, - fn: React.useCallback(() => { - openDialog(({ close }) => ) - }, [roles, defaultRoleId, openDialog]), - }, - ] - - const inlineActions = (user) => [ - { - title: 'Delete', - icon: delete, - fn: () => { - dialogs.open(({ close }) => ) - }, - }, - { - title: 'Edit', - icon: edit, - fn: () => { - dialogs.open(({ close }) => ) - }, - }, - ] - - return ( - }> - - {dialogs.render({ maxWidth: 'xs', fullWidth: true })} - - - - - - - - {pagination.paginated.map((i) => ( - - {columns.map((col) => ( - - {(col.getDisplay || R.identity)(col.getValue(i), i)} - - ))} - - - - - ))} - - - - - - - ) -} diff --git a/catalog/app/containers/Admin/Users/Users.tsx b/catalog/app/containers/Admin/Users/Users.tsx new file mode 100644 index 00000000000..7767ca564cb --- /dev/null +++ b/catalog/app/containers/Admin/Users/Users.tsx @@ -0,0 +1,851 @@ +import cx from 'classnames' +import * as FF from 'final-form' +import * as R from 'ramda' +import * as React from 'react' +import * as redux from 'react-redux' +import * as RF from 'react-final-form' +import * as M from '@material-ui/core' + +import * as Pagination from 'components/Pagination' +import * as Notifications from 'containers/Notifications' +import * as Auth from 'containers/Auth' +import * as Dialogs from 'utils/GlobalDialogs' +import * as GQL from 'utils/GraphQL' +import assertNever from 'utils/assertNever' +import * as Format from 'utils/format' +import * as validators from 'utils/validators' + +import * as Form from '../Form' +import * as Table from '../Table' + +import USERS_QUERY from './gql/Users.generated' +import USER_CREATE_MUTATION from './gql/UserCreate.generated' +import USER_DELETE_MUTATION from './gql/UserDelete.generated' +import USER_SET_EMAIL_MUTATION from './gql/UserSetEmail.generated' +import USER_SET_ROLE_MUTATION from './gql/UserSetRole.generated' +import USER_SET_ACTIVE_MUTATION from './gql/UserSetActive.generated' +import USER_SET_ADMIN_MUTATION from './gql/UserSetAdmin.generated' + +import { UserSelectionFragment as User } from './gql/UserSelection.generated' + +type Role = GQL.DataForDoc['roles'][number] + +const DIALOG_PROPS: Dialogs.ExtraDialogProps = { maxWidth: 'xs', fullWidth: true } + +const useMonoStyles = M.makeStyles((t) => ({ + root: { + fontFamily: t.typography.monospace.fontFamily, + }, +})) + +interface MonoProps { + className?: string + children: React.ReactNode +} + +function Mono({ className, children }: MonoProps) { + const classes = useMonoStyles() + return {children} +} + +const useInviteStyles = M.makeStyles({ + infoIcon: { + fontSize: '1.25em', + verticalAlign: '-3px', + }, +}) + +interface InviteProps { + close: () => void + roles: readonly Role[] + defaultRoleName: string | undefined +} + +function Invite({ close, roles, defaultRoleName }: InviteProps) { + const classes = useInviteStyles() + const create = GQL.useMutation(USER_CREATE_MUTATION) + const { push } = Notifications.use() + + const onSubmit = React.useCallback( + async ({ username, email, roleName }) => { + try { + // XXX: use formspec to convert/validate form values into gql input? + const input = { + name: username, + email, + roleName, + } + const data = await create({ input }) + const r = data.admin.user.create + switch (r.__typename) { + case 'User': + close() + push('User invited') + return + case 'OperationError': + switch (r.name) { + case 'SubscriptionInvalid': + return { [FF.FORM_ERROR]: 'subscriptionInvalid' } + case 'MailSendError': + return { [FF.FORM_ERROR]: 'smtp' } + case 'Conflict': + if (r.message.match(/Email already taken/)) { + return { email: 'taken' } + } + if (r.message.match(/Username already taken/)) { + return { username: 'taken' } + } + } + throw new Error(`Unexpected operation error: [${r.name}] ${r.message}`) + case 'InvalidInput': + const errors: Record = {} + r.errors.forEach((e) => { + switch (e.path) { + case 'input.name': + // e.name should be one of: + // - InvalidUserNameError + // - ReservedUserNameError + errors.username = 'invalid' + break + case 'input.email': + errors.email = 'invalid' + break + default: + throw new Error( + `Unexpected input error at '${e.path}': [${e.name}] ${e.message}`, + ) + } + }) + return errors + default: + return assertNever(r) + } + } catch (e) { + // eslint-disable-next-line no-console + console.error('Error creating user') + // eslint-disable-next-line no-console + console.dir(e) + // TODO: send to sentry? + return { [FF.FORM_ERROR]: 'unexpected' } + } + }, + [create, push, close], + ) + + return ( + + {({ + handleSubmit, + submitting, + submitError, + submitFailed, + error, + hasSubmitErrors, + hasValidationErrors, + modifiedSinceLastSubmit, + }) => ( + <> + Invite a user + +
+ } + label="Username" + fullWidth + margin="normal" + errors={{ + required: 'Enter a username', + taken: 'Username already taken', + invalid: ( + <> + Enter a valid username{' '} + + info + + + ), + }} + autoComplete="off" + /> + } + label="Email" + fullWidth + margin="normal" + errors={{ + required: 'Enter an email', + taken: 'Email already taken', + invalid: 'Enter a valid email', + }} + autoComplete="off" + /> + + {roles.map((r) => ( + + {r.name} + + ))} + + {(!!error || !!submitError) && ( + + )} + + +
+ + + Cancel + + + Invite + + + + )} +
+ ) +} + +interface EditProps { + close: () => void + user: User +} + +function Edit({ close, user: { email: oldEmail, name } }: EditProps) { + const { push } = Notifications.use() + const setEmail = GQL.useMutation(USER_SET_EMAIL_MUTATION) + + const onSubmit = React.useCallback( + async ({ email }) => { + if (email === oldEmail) { + close() + return + } + + try { + const data = await setEmail({ name, email }) + const r = data.admin.user.mutate?.setEmail + if (!r) { + throw new Error() + } + // TODO: switch result + switch (r.__typename) { + // if (APIConnector.HTTPError.is(e, 400, /Another user already has that email/)) { + // return { + // email: 'taken', + // } + // } + // if (APIConnector.HTTPError.is(e, 400, /Invalid email/)) { + // return { + // email: 'invalid', + // } + // } + default: + } + + close() + push('Changes saved') + } catch (e) { + // eslint-disable-next-line no-console + console.error('Error changing email') + // eslint-disable-next-line no-console + console.dir(e) + return { + [FF.FORM_ERROR]: 'unexpected', + } + } + }, + [close, name, oldEmail, setEmail, push], + ) + + return ( + + {({ + handleSubmit, + submitting, + submitFailed, + error, + hasSubmitErrors, + hasValidationErrors, + modifiedSinceLastSubmit, + }) => ( + <> + Edit user: "{name}" + +
+ } + label="Email" + fullWidth + margin="normal" + errors={{ + required: 'Enter an email', + taken: 'Email already taken', + invalid: 'Enter a valid email', + }} + autoComplete="off" + /> + {submitFailed && ( + + )} + + +
+ + + Cancel + + + Save + + + + )} +
+ ) +} + +function ActionProgress({ children }: React.PropsWithChildren<{}>) { + return ( + + {children} + + ) +} + +interface DeleteProps { + close: () => void + name: string +} + +function Delete({ name, close }: DeleteProps) { + const { push } = Notifications.use() + const del = GQL.useMutation(USER_DELETE_MUTATION) + const onSubmit = React.useCallback(async () => { + try { + const data = await del({ name }) + const r = data.admin.user.mutate?.delete + if (!r) return { [FF.FORM_ERROR]: 'notFound' } + switch (r.__typename) { + case 'Ok': + close() + push(`User "${name}" deleted`) + return + case 'InvalidInput': + const [e] = r.errors + throw new Error( + `Unexpected input error at '${e.path}': [${e.name}] ${e.message}`, + ) + case 'OperationError': + if (r.name === 'DeleteSelf') { + return { [FF.FORM_ERROR]: 'deleteSelf' } + } + throw new Error(`Unexpected operation error: [${r.name}] ${r.message}`) + default: + assertNever(r) + } + } catch (e) { + // eslint-disable-next-line no-console + console.error('Error deleting user') + // eslint-disable-next-line no-console + console.dir(e) + // TODO: send to sentry? + return { [FF.FORM_ERROR]: 'unexpected' } + } + }, [del, name, close, push]) + + return ( + + {({ handleSubmit, submitting, submitError }) => ( + <> + Delete a user + + You are about to delete user "{name}". +
+ This operation is irreversible. +
+ {!!submitError && ( + + )} +
+ + {submitting && Deleting...} + + Cancel + + + Delete + + + + )} +
+ ) +} + +interface ConfirmAdminRightsProps { + admin: boolean + name: string + close: Dialogs.Close +} + +function ConfirmAdminRights({ name, admin, close }: ConfirmAdminRightsProps) { + const { push } = Notifications.use() + const setAdmin = GQL.useMutation(USER_SET_ADMIN_MUTATION) + + const doChange = React.useCallback( + () => + close( + setAdmin({ name, admin }) + .then((data) => { + const r = data.admin.user.mutate?.setAdmin + switch (r?.__typename) { + case 'User': + return true + case undefined: // should not happen + throw new Error('User not found') + case 'InvalidInput': // should not happen + const [e] = r.errors + throw new Error( + `Unexpected input error at '${e.path}': [${e.name}] ${e.message}`, + ) + case 'OperationError': // should not happen + throw new Error(`Unexpected operation error: [${r.name}] ${r.message}`) + default: + assertNever(r) + } + }) + .catch((e) => { + push(`Could not change admin status for "${name}"`) + // eslint-disable-next-line no-console + console.error('Could not change user admin status', { name, admin }) + // eslint-disable-next-line no-console + console.dir(e) + throw e // revert value change in + }), + ), + [admin, close, name, setAdmin, push], + ) + + return ( + <> + {admin ? 'Grant' : 'Revoke'} admin rights + + You are about to {admin ? 'grant admin rights to' : 'revoke admin rights from'}{' '} + "{name}". + + + close(false)} color="primary"> + Cancel + + + {admin ? 'Grant' : 'Revoke'} + + + + ) +} + +const useUsernameStyles = M.makeStyles((t) => ({ + root: { + alignItems: 'center', + display: 'flex', + }, + admin: { + fontWeight: 600, + }, + icon: { + fontSize: '1em', + marginLeft: `calc(-1em - ${t.spacing(0.5)}px)`, + marginRight: t.spacing(0.5), + }, +})) + +interface UsernameProps extends React.HTMLProps { + admin?: boolean +} + +function Username({ className, admin = false, children, ...props }: UsernameProps) { + const classes = useUsernameStyles() + return ( + + {admin && security} + {children} + + ) +} + +interface EditableRenderProps { + change: (v: T) => void + busy: boolean + value: T +} + +interface EditableProps { + value: T + onChange: (v: T) => void + children: (props: EditableRenderProps) => JSX.Element +} + +function Editable({ value, onChange, children }: EditableProps) { + const [busy, setBusy] = React.useState(false) + const [savedValue, saveValue] = React.useState(value) + const change = React.useCallback( + (newValue: T) => { + if (savedValue === newValue) return + if (busy) return + setBusy(true) + saveValue(newValue) + Promise.resolve(onChange(newValue)) + .catch(() => { + saveValue(savedValue) + }) + .finally(() => { + setBusy(false) + }) + }, + [onChange, busy, setBusy, savedValue, saveValue], + ) + + return children({ change, busy, value: savedValue }) +} + +function UsersSkeleton() { + return ( + + + + + ) +} + +// not a valid role name +const emptyRole = '' + +interface ColumnDisplayProps { + roles: readonly Role[] + setActive: (name: string, active: boolean) => Promise + setRole: (name: string, roleName: string) => Promise + openDialog: Dialogs.Open + isSelf: boolean +} + +const columns: Table.Column[] = [ + { + id: 'isActive', + label: 'Enabled', + getValue: (u) => u.isActive, + getDisplay: (v: boolean, u, { setActive, isSelf }: ColumnDisplayProps) => + isSelf ? ( + + ) : ( + setActive(u.name, active)}> + {({ change, busy, value }) => ( + change(e.target.checked)} + disabled={busy} + color="default" + /> + )} + + ), + }, + { + id: 'username', + label: 'Username', + getValue: (u) => u.name, + getDisplay: (_name: string, u) => {u.name}, + props: { component: 'th', scope: 'row' }, + }, + { + id: 'email', + label: 'Email', + getValue: (u) => u.email, + }, + { + id: 'role', + label: 'Role', + getValue: (u) => u.role?.name, + getDisplay: (v: string | undefined, u, { roles, setRole }: ColumnDisplayProps) => ( + setRole(u.name, role)}> + {({ change, busy, value }) => ( + change(e.target.value as string)} + disabled={busy} + renderValue={R.identity} + > + {roles.map((r) => ( + + {r.name} + + ))} + + )} + + ), + }, + { + id: 'dateJoined', + label: 'Date joined', + getValue: (u) => u.dateJoined, + getDisplay: (v: Date) => ( + + + + ), + }, + { + id: 'lastLogin', + label: 'Last login', + getValue: (u) => u.lastLogin, + getDisplay: (v: Date) => ( + + + + ), + }, + { + id: 'isAdmin', + label: 'Admin', + hint: 'Admins can see this page, add/remove users, and make/remove admins', + getValue: (u) => u.isAdmin, + getDisplay: (v: boolean, { name }, { openDialog, isSelf }: ColumnDisplayProps) => + isSelf ? ( + + ) : ( + + openDialog( + ({ close }) => , + DIALOG_PROPS, + ).then((res) => { + if (!res) throw new Error('cancel') + }) + } + > + {({ change, busy, value }) => ( + change(e.target.checked)} + disabled={busy} + color="default" + /> + )} + + ), + }, +] + +function useSetActive() { + const { push } = Notifications.use() + const setActive = GQL.useMutation(USER_SET_ACTIVE_MUTATION) + + return React.useCallback( + async (name: string, active: boolean) => { + try { + const resp = await setActive({ name, active }) + // TODO: switch + switch (resp) { + } + } catch (e) { + push(`Error ${active ? 'enabling' : 'disabling'} "${name}"`) + // eslint-disable-next-line no-console + console.error('Error (de)activating user', { name, active }) + // eslint-disable-next-line no-console + console.dir(e) + // TODO + // throw e + } + }, + [setActive, push], + ) +} + +function useSetRole() { + const { push } = Notifications.use() + const setRole = GQL.useMutation(USER_SET_ROLE_MUTATION) + + return React.useCallback( + async (name: string, roleName: string) => { + try { + const resp = await setRole({ name, roleName }) + const r = resp.admin.user.mutate?.setRole + // TODO: switch result + switch (r) { + } + } catch (e) { + push(`Error changing role for "${name}"`) + // eslint-disable-next-line no-console + console.error('Error chaging role', { name, roleName }) + // eslint-disable-next-line no-console + console.dir(e) + throw e + } + }, + [setRole, push], + ) +} + +export default function Users() { + const data = GQL.useQueryS(USERS_QUERY) + const rows = data.admin.user.list + const roles = data.roles + const defaultRoleName = data.defaultRole?.name + + const openDialog = Dialogs.use() + + const setActive = useSetActive() + const setRole = useSetRole() + + const filtering = Table.useFiltering({ + rows, + filterBy: ({ email, name }) => email + name, + }) + const ordering = Table.useOrdering({ + rows: filtering.filtered, + column: columns[0], + }) + const pagination = Pagination.use(ordering.ordered, { + getItemId: (u: User) => u.name, + } as $TSFixMe) + + const toolbarActions = [ + { + title: 'Invite', + icon: add, + fn: React.useCallback(() => { + openDialog( + ({ close }) => , + DIALOG_PROPS, + ) + }, [roles, defaultRoleName, openDialog]), + }, + ] + + const self: string = redux.useSelector(Auth.selectors.username) + + const inlineActions = (user: User) => [ + user.name === self + ? null + : { + title: 'Delete', + icon: delete, + fn: () => { + openDialog( + ({ close }) => , + DIALOG_PROPS, + ) + }, + }, + { + title: 'Edit', + icon: edit, + fn: () => { + openDialog(({ close }) => , DIALOG_PROPS) + }, + }, + ] + + const getDisplayProps = (u: User): ColumnDisplayProps => ({ + setRole, + setActive, + roles, + openDialog, + isSelf: u.name === self, + }) + + return ( + }> + + + + + + + + + {pagination.paginated.map((i: User) => ( + + {columns.map((col) => ( + + {(col.getDisplay || R.identity)( + col.getValue(i), + i, + getDisplayProps(i), + )} + + ))} + + + + + ))} + + + + + + + ) +} diff --git a/catalog/app/containers/Admin/Users/gql/UserCreate.generated.ts b/catalog/app/containers/Admin/Users/gql/UserCreate.generated.ts new file mode 100644 index 00000000000..ca678da00a1 --- /dev/null +++ b/catalog/app/containers/Admin/Users/gql/UserCreate.generated.ts @@ -0,0 +1,104 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import type { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/core' +import * as Types from '../../../../model/graphql/types.generated' + +import { + UserResultSelection_User_Fragment, + UserResultSelection_InvalidInput_Fragment, + UserResultSelection_OperationError_Fragment, + UserResultSelectionFragmentDoc, +} from './UserResultSelection.generated' + +export type containers_Admin_Users_gql_UserCreateMutationVariables = Types.Exact<{ + input: Types.UserInput +}> + +export type containers_Admin_Users_gql_UserCreateMutation = { + readonly __typename: 'Mutation' +} & { + readonly admin: { readonly __typename: 'AdminMutations' } & { + readonly user: { readonly __typename: 'UserAdminMutations' } & { + readonly create: + | ({ readonly __typename: 'User' } & UserResultSelection_User_Fragment) + | ({ + readonly __typename: 'InvalidInput' + } & UserResultSelection_InvalidInput_Fragment) + | ({ + readonly __typename: 'OperationError' + } & UserResultSelection_OperationError_Fragment) + } + } +} + +export const containers_Admin_Users_gql_UserCreateDocument = { + kind: 'Document', + definitions: [ + { + kind: 'OperationDefinition', + operation: 'mutation', + name: { kind: 'Name', value: 'containers_Admin_Users_gql_UserCreate' }, + variableDefinitions: [ + { + kind: 'VariableDefinition', + variable: { kind: 'Variable', name: { kind: 'Name', value: 'input' } }, + type: { + kind: 'NonNullType', + type: { kind: 'NamedType', name: { kind: 'Name', value: 'UserInput' } }, + }, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'admin' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'user' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'create' }, + arguments: [ + { + kind: 'Argument', + name: { kind: 'Name', value: 'input' }, + value: { + kind: 'Variable', + name: { kind: 'Name', value: 'input' }, + }, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'FragmentSpread', + name: { kind: 'Name', value: 'UserResultSelection' }, + }, + ], + }, + }, + ], + }, + }, + ], + }, + }, + ], + }, + }, + ...UserResultSelectionFragmentDoc.definitions, + ], +} as unknown as DocumentNode< + containers_Admin_Users_gql_UserCreateMutation, + containers_Admin_Users_gql_UserCreateMutationVariables +> + +export { containers_Admin_Users_gql_UserCreateDocument as default } diff --git a/catalog/app/containers/Admin/Users/gql/UserCreate.graphql b/catalog/app/containers/Admin/Users/gql/UserCreate.graphql new file mode 100644 index 00000000000..287d9b2f0f6 --- /dev/null +++ b/catalog/app/containers/Admin/Users/gql/UserCreate.graphql @@ -0,0 +1,11 @@ +# import UserResultSelection from "./UserResultSelection.graphql" + +mutation ($input: UserInput!) { + admin { + user { + create(input: $input) { + ...UserResultSelection + } + } + } +} diff --git a/catalog/app/containers/Admin/Users/gql/UserDelete.generated.ts b/catalog/app/containers/Admin/Users/gql/UserDelete.generated.ts new file mode 100644 index 00000000000..7b8a68057d2 --- /dev/null +++ b/catalog/app/containers/Admin/Users/gql/UserDelete.generated.ts @@ -0,0 +1,176 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import type { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/core' +import * as Types from '../../../../model/graphql/types.generated' + +export type containers_Admin_Users_gql_UserDeleteMutationVariables = Types.Exact<{ + name: Types.Scalars['String'] +}> + +export type containers_Admin_Users_gql_UserDeleteMutation = { + readonly __typename: 'Mutation' +} & { + readonly admin: { readonly __typename: 'AdminMutations' } & { + readonly user: { readonly __typename: 'UserAdminMutations' } & { + readonly mutate: Types.Maybe< + { readonly __typename: 'MutateUserAdminMutations' } & { + readonly delete: + | { readonly __typename: 'Ok' } + | ({ readonly __typename: 'InvalidInput' } & { + readonly errors: ReadonlyArray< + { readonly __typename: 'InputError' } & Pick< + Types.InputError, + 'path' | 'message' | 'name' | 'context' + > + > + }) + | ({ readonly __typename: 'OperationError' } & Pick< + Types.OperationError, + 'message' | 'name' | 'context' + >) + } + > + } + } +} + +export const containers_Admin_Users_gql_UserDeleteDocument = { + kind: 'Document', + definitions: [ + { + kind: 'OperationDefinition', + operation: 'mutation', + name: { kind: 'Name', value: 'containers_Admin_Users_gql_UserDelete' }, + variableDefinitions: [ + { + kind: 'VariableDefinition', + variable: { kind: 'Variable', name: { kind: 'Name', value: 'name' } }, + type: { + kind: 'NonNullType', + type: { kind: 'NamedType', name: { kind: 'Name', value: 'String' } }, + }, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'admin' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'user' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'mutate' }, + arguments: [ + { + kind: 'Argument', + name: { kind: 'Name', value: 'name' }, + value: { + kind: 'Variable', + name: { kind: 'Name', value: 'name' }, + }, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'delete' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: '__typename' }, + }, + { + kind: 'InlineFragment', + typeCondition: { + kind: 'NamedType', + name: { kind: 'Name', value: 'InvalidInput' }, + }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'errors' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'path' }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'message' }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'name' }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'context' }, + }, + ], + }, + }, + ], + }, + }, + { + kind: 'InlineFragment', + typeCondition: { + kind: 'NamedType', + name: { kind: 'Name', value: 'OperationError' }, + }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'message' }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'name' }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'context' }, + }, + ], + }, + }, + ], + }, + }, + ], + }, + }, + ], + }, + }, + ], + }, + }, + ], + }, + }, + ], +} as unknown as DocumentNode< + containers_Admin_Users_gql_UserDeleteMutation, + containers_Admin_Users_gql_UserDeleteMutationVariables +> + +export { containers_Admin_Users_gql_UserDeleteDocument as default } diff --git a/catalog/app/containers/Admin/Users/gql/UserDelete.graphql b/catalog/app/containers/Admin/Users/gql/UserDelete.graphql new file mode 100644 index 00000000000..78d453b9b9f --- /dev/null +++ b/catalog/app/containers/Admin/Users/gql/UserDelete.graphql @@ -0,0 +1,24 @@ +mutation ($name: String!) { + admin { + user { + mutate(name: $name) { + delete { + __typename + ... on InvalidInput { + errors { + path + message + name + context + } + } + ... on OperationError { + message + name + context + } + } + } + } + } +} diff --git a/catalog/app/containers/Admin/Users/gql/UserResultSelection.generated.ts b/catalog/app/containers/Admin/Users/gql/UserResultSelection.generated.ts new file mode 100644 index 00000000000..1833ee2e578 --- /dev/null +++ b/catalog/app/containers/Admin/Users/gql/UserResultSelection.generated.ts @@ -0,0 +1,103 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import type { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/core' +import * as Types from '../../../../model/graphql/types.generated' + +import { + UserSelectionFragment, + UserSelectionFragmentDoc, +} from './UserSelection.generated' + +export type UserResultSelection_User_Fragment = { + readonly __typename: 'User' +} & UserSelectionFragment + +export type UserResultSelection_InvalidInput_Fragment = { + readonly __typename: 'InvalidInput' +} & { + readonly errors: ReadonlyArray< + { readonly __typename: 'InputError' } & Pick< + Types.InputError, + 'path' | 'message' | 'name' | 'context' + > + > +} + +export type UserResultSelection_OperationError_Fragment = { + readonly __typename: 'OperationError' +} & Pick + +export type UserResultSelectionFragment = + | UserResultSelection_User_Fragment + | UserResultSelection_InvalidInput_Fragment + | UserResultSelection_OperationError_Fragment + +export const UserResultSelectionFragmentDoc = { + kind: 'Document', + definitions: [ + { + kind: 'FragmentDefinition', + name: { kind: 'Name', value: 'UserResultSelection' }, + typeCondition: { kind: 'NamedType', name: { kind: 'Name', value: 'UserResult' } }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: '__typename' } }, + { + kind: 'InlineFragment', + typeCondition: { kind: 'NamedType', name: { kind: 'Name', value: 'User' } }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'FragmentSpread', + name: { kind: 'Name', value: 'UserSelection' }, + }, + ], + }, + }, + { + kind: 'InlineFragment', + typeCondition: { + kind: 'NamedType', + name: { kind: 'Name', value: 'InvalidInput' }, + }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'errors' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'path' } }, + { kind: 'Field', name: { kind: 'Name', value: 'message' } }, + { kind: 'Field', name: { kind: 'Name', value: 'name' } }, + { kind: 'Field', name: { kind: 'Name', value: 'context' } }, + ], + }, + }, + ], + }, + }, + { + kind: 'InlineFragment', + typeCondition: { + kind: 'NamedType', + name: { kind: 'Name', value: 'OperationError' }, + }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'message' } }, + { kind: 'Field', name: { kind: 'Name', value: 'name' } }, + { kind: 'Field', name: { kind: 'Name', value: 'context' } }, + ], + }, + }, + ], + }, + }, + ...UserSelectionFragmentDoc.definitions, + ], +} as unknown as DocumentNode diff --git a/catalog/app/containers/Admin/Users/gql/UserResultSelection.graphql b/catalog/app/containers/Admin/Users/gql/UserResultSelection.graphql new file mode 100644 index 00000000000..86ae6e80d97 --- /dev/null +++ b/catalog/app/containers/Admin/Users/gql/UserResultSelection.graphql @@ -0,0 +1,19 @@ +# import UserSelection from "./UserSelection.graphql" + +fragment UserResultSelection on UserResult { + __typename + ... on User { ...UserSelection } + ... on InvalidInput { + errors { + path + message + name + context + } + } + ... on OperationError { + message + name + context + } +} diff --git a/catalog/app/containers/Admin/Users/gql/UserSelection.generated.ts b/catalog/app/containers/Admin/Users/gql/UserSelection.generated.ts new file mode 100644 index 00000000000..0368d91f12b --- /dev/null +++ b/catalog/app/containers/Admin/Users/gql/UserSelection.generated.ts @@ -0,0 +1,86 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import type { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/core' +import * as Types from '../../../../model/graphql/types.generated' + +export type UserSelectionFragment = { readonly __typename: 'User' } & Pick< + Types.User, + | 'name' + | 'email' + | 'dateJoined' + | 'lastLogin' + | 'isActive' + | 'isAdmin' + | 'isSsoOnly' + | 'isService' +> & { + readonly role: Types.Maybe< + | ({ readonly __typename: 'UnmanagedRole' } & Pick< + Types.UnmanagedRole, + 'id' | 'name' + >) + | ({ readonly __typename: 'ManagedRole' } & Pick) + > + } + +export const UserSelectionFragmentDoc = { + kind: 'Document', + definitions: [ + { + kind: 'FragmentDefinition', + name: { kind: 'Name', value: 'UserSelection' }, + typeCondition: { kind: 'NamedType', name: { kind: 'Name', value: 'User' } }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: '__typename' } }, + { kind: 'Field', name: { kind: 'Name', value: 'name' } }, + { kind: 'Field', name: { kind: 'Name', value: 'email' } }, + { kind: 'Field', name: { kind: 'Name', value: 'dateJoined' } }, + { kind: 'Field', name: { kind: 'Name', value: 'lastLogin' } }, + { kind: 'Field', name: { kind: 'Name', value: 'isActive' } }, + { kind: 'Field', name: { kind: 'Name', value: 'isAdmin' } }, + { kind: 'Field', name: { kind: 'Name', value: 'isSsoOnly' } }, + { kind: 'Field', name: { kind: 'Name', value: 'isService' } }, + { + kind: 'Field', + name: { kind: 'Name', value: 'role' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: '__typename' } }, + { + kind: 'InlineFragment', + typeCondition: { + kind: 'NamedType', + name: { kind: 'Name', value: 'ManagedRole' }, + }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'id' } }, + { kind: 'Field', name: { kind: 'Name', value: 'name' } }, + ], + }, + }, + { + kind: 'InlineFragment', + typeCondition: { + kind: 'NamedType', + name: { kind: 'Name', value: 'UnmanagedRole' }, + }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'id' } }, + { kind: 'Field', name: { kind: 'Name', value: 'name' } }, + ], + }, + }, + ], + }, + }, + ], + }, + }, + ], +} as unknown as DocumentNode diff --git a/catalog/app/containers/Admin/Users/gql/UserSelection.graphql b/catalog/app/containers/Admin/Users/gql/UserSelection.graphql new file mode 100644 index 00000000000..349a0304e63 --- /dev/null +++ b/catalog/app/containers/Admin/Users/gql/UserSelection.graphql @@ -0,0 +1,22 @@ +fragment UserSelection on User { + __typename + name + email + dateJoined + lastLogin + isActive + isAdmin + isSsoOnly + isService + role { + __typename + ... on ManagedRole { + id + name + } + ... on UnmanagedRole { + id + name + } + } +} diff --git a/catalog/app/containers/Admin/Users/gql/UserSetActive.generated.ts b/catalog/app/containers/Admin/Users/gql/UserSetActive.generated.ts new file mode 100644 index 00000000000..343a4cff8fd --- /dev/null +++ b/catalog/app/containers/Admin/Users/gql/UserSetActive.generated.ts @@ -0,0 +1,136 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import type { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/core' +import * as Types from '../../../../model/graphql/types.generated' + +import { + UserResultSelection_User_Fragment, + UserResultSelection_InvalidInput_Fragment, + UserResultSelection_OperationError_Fragment, + UserResultSelectionFragmentDoc, +} from './UserResultSelection.generated' + +export type containers_Admin_Users_gql_UserSetActiveMutationVariables = Types.Exact<{ + name: Types.Scalars['String'] + active: Types.Scalars['Boolean'] +}> + +export type containers_Admin_Users_gql_UserSetActiveMutation = { + readonly __typename: 'Mutation' +} & { + readonly admin: { readonly __typename: 'AdminMutations' } & { + readonly user: { readonly __typename: 'UserAdminMutations' } & { + readonly mutate: Types.Maybe< + { readonly __typename: 'MutateUserAdminMutations' } & { + readonly setActive: + | ({ readonly __typename: 'User' } & UserResultSelection_User_Fragment) + | ({ + readonly __typename: 'InvalidInput' + } & UserResultSelection_InvalidInput_Fragment) + | ({ + readonly __typename: 'OperationError' + } & UserResultSelection_OperationError_Fragment) + } + > + } + } +} + +export const containers_Admin_Users_gql_UserSetActiveDocument = { + kind: 'Document', + definitions: [ + { + kind: 'OperationDefinition', + operation: 'mutation', + name: { kind: 'Name', value: 'containers_Admin_Users_gql_UserSetActive' }, + variableDefinitions: [ + { + kind: 'VariableDefinition', + variable: { kind: 'Variable', name: { kind: 'Name', value: 'name' } }, + type: { + kind: 'NonNullType', + type: { kind: 'NamedType', name: { kind: 'Name', value: 'String' } }, + }, + }, + { + kind: 'VariableDefinition', + variable: { kind: 'Variable', name: { kind: 'Name', value: 'active' } }, + type: { + kind: 'NonNullType', + type: { kind: 'NamedType', name: { kind: 'Name', value: 'Boolean' } }, + }, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'admin' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'user' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'mutate' }, + arguments: [ + { + kind: 'Argument', + name: { kind: 'Name', value: 'name' }, + value: { + kind: 'Variable', + name: { kind: 'Name', value: 'name' }, + }, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'setActive' }, + arguments: [ + { + kind: 'Argument', + name: { kind: 'Name', value: 'active' }, + value: { + kind: 'Variable', + name: { kind: 'Name', value: 'active' }, + }, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'FragmentSpread', + name: { kind: 'Name', value: 'UserResultSelection' }, + }, + ], + }, + }, + ], + }, + }, + ], + }, + }, + ], + }, + }, + ], + }, + }, + ...UserResultSelectionFragmentDoc.definitions, + ], +} as unknown as DocumentNode< + containers_Admin_Users_gql_UserSetActiveMutation, + containers_Admin_Users_gql_UserSetActiveMutationVariables +> + +export { containers_Admin_Users_gql_UserSetActiveDocument as default } diff --git a/catalog/app/containers/Admin/Users/gql/UserSetActive.graphql b/catalog/app/containers/Admin/Users/gql/UserSetActive.graphql new file mode 100644 index 00000000000..0c1089ea8a6 --- /dev/null +++ b/catalog/app/containers/Admin/Users/gql/UserSetActive.graphql @@ -0,0 +1,13 @@ +# import UserResultSelection from "./UserResultSelection.graphql" + +mutation ($name: String!, $active: Boolean!) { + admin { + user { + mutate(name: $name) { + setActive(active: $active) { + ...UserResultSelection + } + } + } + } +} diff --git a/catalog/app/containers/Admin/Users/gql/UserSetAdmin.generated.ts b/catalog/app/containers/Admin/Users/gql/UserSetAdmin.generated.ts new file mode 100644 index 00000000000..87c7a5c85ac --- /dev/null +++ b/catalog/app/containers/Admin/Users/gql/UserSetAdmin.generated.ts @@ -0,0 +1,136 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import type { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/core' +import * as Types from '../../../../model/graphql/types.generated' + +import { + UserResultSelection_User_Fragment, + UserResultSelection_InvalidInput_Fragment, + UserResultSelection_OperationError_Fragment, + UserResultSelectionFragmentDoc, +} from './UserResultSelection.generated' + +export type containers_Admin_Users_gql_UserSetAdminMutationVariables = Types.Exact<{ + name: Types.Scalars['String'] + admin: Types.Scalars['Boolean'] +}> + +export type containers_Admin_Users_gql_UserSetAdminMutation = { + readonly __typename: 'Mutation' +} & { + readonly admin: { readonly __typename: 'AdminMutations' } & { + readonly user: { readonly __typename: 'UserAdminMutations' } & { + readonly mutate: Types.Maybe< + { readonly __typename: 'MutateUserAdminMutations' } & { + readonly setAdmin: + | ({ readonly __typename: 'User' } & UserResultSelection_User_Fragment) + | ({ + readonly __typename: 'InvalidInput' + } & UserResultSelection_InvalidInput_Fragment) + | ({ + readonly __typename: 'OperationError' + } & UserResultSelection_OperationError_Fragment) + } + > + } + } +} + +export const containers_Admin_Users_gql_UserSetAdminDocument = { + kind: 'Document', + definitions: [ + { + kind: 'OperationDefinition', + operation: 'mutation', + name: { kind: 'Name', value: 'containers_Admin_Users_gql_UserSetAdmin' }, + variableDefinitions: [ + { + kind: 'VariableDefinition', + variable: { kind: 'Variable', name: { kind: 'Name', value: 'name' } }, + type: { + kind: 'NonNullType', + type: { kind: 'NamedType', name: { kind: 'Name', value: 'String' } }, + }, + }, + { + kind: 'VariableDefinition', + variable: { kind: 'Variable', name: { kind: 'Name', value: 'admin' } }, + type: { + kind: 'NonNullType', + type: { kind: 'NamedType', name: { kind: 'Name', value: 'Boolean' } }, + }, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'admin' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'user' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'mutate' }, + arguments: [ + { + kind: 'Argument', + name: { kind: 'Name', value: 'name' }, + value: { + kind: 'Variable', + name: { kind: 'Name', value: 'name' }, + }, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'setAdmin' }, + arguments: [ + { + kind: 'Argument', + name: { kind: 'Name', value: 'admin' }, + value: { + kind: 'Variable', + name: { kind: 'Name', value: 'admin' }, + }, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'FragmentSpread', + name: { kind: 'Name', value: 'UserResultSelection' }, + }, + ], + }, + }, + ], + }, + }, + ], + }, + }, + ], + }, + }, + ], + }, + }, + ...UserResultSelectionFragmentDoc.definitions, + ], +} as unknown as DocumentNode< + containers_Admin_Users_gql_UserSetAdminMutation, + containers_Admin_Users_gql_UserSetAdminMutationVariables +> + +export { containers_Admin_Users_gql_UserSetAdminDocument as default } diff --git a/catalog/app/containers/Admin/Users/gql/UserSetAdmin.graphql b/catalog/app/containers/Admin/Users/gql/UserSetAdmin.graphql new file mode 100644 index 00000000000..5f400011f04 --- /dev/null +++ b/catalog/app/containers/Admin/Users/gql/UserSetAdmin.graphql @@ -0,0 +1,13 @@ +# import UserResultSelection from "./UserResultSelection.graphql" + +mutation ($name: String!, $admin: Boolean!) { + admin { + user { + mutate(name: $name) { + setAdmin(admin: $admin) { + ...UserResultSelection + } + } + } + } +} diff --git a/catalog/app/containers/Admin/Users/gql/UserSetEmail.generated.ts b/catalog/app/containers/Admin/Users/gql/UserSetEmail.generated.ts new file mode 100644 index 00000000000..8776fb06f11 --- /dev/null +++ b/catalog/app/containers/Admin/Users/gql/UserSetEmail.generated.ts @@ -0,0 +1,136 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import type { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/core' +import * as Types from '../../../../model/graphql/types.generated' + +import { + UserResultSelection_User_Fragment, + UserResultSelection_InvalidInput_Fragment, + UserResultSelection_OperationError_Fragment, + UserResultSelectionFragmentDoc, +} from './UserResultSelection.generated' + +export type containers_Admin_Users_gql_UserSetEmailMutationVariables = Types.Exact<{ + name: Types.Scalars['String'] + email: Types.Scalars['String'] +}> + +export type containers_Admin_Users_gql_UserSetEmailMutation = { + readonly __typename: 'Mutation' +} & { + readonly admin: { readonly __typename: 'AdminMutations' } & { + readonly user: { readonly __typename: 'UserAdminMutations' } & { + readonly mutate: Types.Maybe< + { readonly __typename: 'MutateUserAdminMutations' } & { + readonly setEmail: + | ({ readonly __typename: 'User' } & UserResultSelection_User_Fragment) + | ({ + readonly __typename: 'InvalidInput' + } & UserResultSelection_InvalidInput_Fragment) + | ({ + readonly __typename: 'OperationError' + } & UserResultSelection_OperationError_Fragment) + } + > + } + } +} + +export const containers_Admin_Users_gql_UserSetEmailDocument = { + kind: 'Document', + definitions: [ + { + kind: 'OperationDefinition', + operation: 'mutation', + name: { kind: 'Name', value: 'containers_Admin_Users_gql_UserSetEmail' }, + variableDefinitions: [ + { + kind: 'VariableDefinition', + variable: { kind: 'Variable', name: { kind: 'Name', value: 'name' } }, + type: { + kind: 'NonNullType', + type: { kind: 'NamedType', name: { kind: 'Name', value: 'String' } }, + }, + }, + { + kind: 'VariableDefinition', + variable: { kind: 'Variable', name: { kind: 'Name', value: 'email' } }, + type: { + kind: 'NonNullType', + type: { kind: 'NamedType', name: { kind: 'Name', value: 'String' } }, + }, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'admin' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'user' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'mutate' }, + arguments: [ + { + kind: 'Argument', + name: { kind: 'Name', value: 'name' }, + value: { + kind: 'Variable', + name: { kind: 'Name', value: 'name' }, + }, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'setEmail' }, + arguments: [ + { + kind: 'Argument', + name: { kind: 'Name', value: 'email' }, + value: { + kind: 'Variable', + name: { kind: 'Name', value: 'email' }, + }, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'FragmentSpread', + name: { kind: 'Name', value: 'UserResultSelection' }, + }, + ], + }, + }, + ], + }, + }, + ], + }, + }, + ], + }, + }, + ], + }, + }, + ...UserResultSelectionFragmentDoc.definitions, + ], +} as unknown as DocumentNode< + containers_Admin_Users_gql_UserSetEmailMutation, + containers_Admin_Users_gql_UserSetEmailMutationVariables +> + +export { containers_Admin_Users_gql_UserSetEmailDocument as default } diff --git a/catalog/app/containers/Admin/Users/gql/UserSetEmail.graphql b/catalog/app/containers/Admin/Users/gql/UserSetEmail.graphql new file mode 100644 index 00000000000..f64a6a3ffdd --- /dev/null +++ b/catalog/app/containers/Admin/Users/gql/UserSetEmail.graphql @@ -0,0 +1,13 @@ +# import UserResultSelection from "./UserResultSelection.graphql" + +mutation ($name: String!, $email: String!) { + admin { + user { + mutate(name: $name) { + setEmail(email: $email) { + ...UserResultSelection + } + } + } + } +} diff --git a/catalog/app/containers/Admin/Users/gql/UserSetRole.generated.ts b/catalog/app/containers/Admin/Users/gql/UserSetRole.generated.ts new file mode 100644 index 00000000000..78b0b118444 --- /dev/null +++ b/catalog/app/containers/Admin/Users/gql/UserSetRole.generated.ts @@ -0,0 +1,136 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import type { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/core' +import * as Types from '../../../../model/graphql/types.generated' + +import { + UserResultSelection_User_Fragment, + UserResultSelection_InvalidInput_Fragment, + UserResultSelection_OperationError_Fragment, + UserResultSelectionFragmentDoc, +} from './UserResultSelection.generated' + +export type containers_Admin_Users_gql_UserSetRoleMutationVariables = Types.Exact<{ + name: Types.Scalars['String'] + roleName: Types.Scalars['String'] +}> + +export type containers_Admin_Users_gql_UserSetRoleMutation = { + readonly __typename: 'Mutation' +} & { + readonly admin: { readonly __typename: 'AdminMutations' } & { + readonly user: { readonly __typename: 'UserAdminMutations' } & { + readonly mutate: Types.Maybe< + { readonly __typename: 'MutateUserAdminMutations' } & { + readonly setRole: + | ({ readonly __typename: 'User' } & UserResultSelection_User_Fragment) + | ({ + readonly __typename: 'InvalidInput' + } & UserResultSelection_InvalidInput_Fragment) + | ({ + readonly __typename: 'OperationError' + } & UserResultSelection_OperationError_Fragment) + } + > + } + } +} + +export const containers_Admin_Users_gql_UserSetRoleDocument = { + kind: 'Document', + definitions: [ + { + kind: 'OperationDefinition', + operation: 'mutation', + name: { kind: 'Name', value: 'containers_Admin_Users_gql_UserSetRole' }, + variableDefinitions: [ + { + kind: 'VariableDefinition', + variable: { kind: 'Variable', name: { kind: 'Name', value: 'name' } }, + type: { + kind: 'NonNullType', + type: { kind: 'NamedType', name: { kind: 'Name', value: 'String' } }, + }, + }, + { + kind: 'VariableDefinition', + variable: { kind: 'Variable', name: { kind: 'Name', value: 'roleName' } }, + type: { + kind: 'NonNullType', + type: { kind: 'NamedType', name: { kind: 'Name', value: 'String' } }, + }, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'admin' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'user' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'mutate' }, + arguments: [ + { + kind: 'Argument', + name: { kind: 'Name', value: 'name' }, + value: { + kind: 'Variable', + name: { kind: 'Name', value: 'name' }, + }, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'setRole' }, + arguments: [ + { + kind: 'Argument', + name: { kind: 'Name', value: 'roleName' }, + value: { + kind: 'Variable', + name: { kind: 'Name', value: 'roleName' }, + }, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'FragmentSpread', + name: { kind: 'Name', value: 'UserResultSelection' }, + }, + ], + }, + }, + ], + }, + }, + ], + }, + }, + ], + }, + }, + ], + }, + }, + ...UserResultSelectionFragmentDoc.definitions, + ], +} as unknown as DocumentNode< + containers_Admin_Users_gql_UserSetRoleMutation, + containers_Admin_Users_gql_UserSetRoleMutationVariables +> + +export { containers_Admin_Users_gql_UserSetRoleDocument as default } diff --git a/catalog/app/containers/Admin/Users/gql/UserSetRole.graphql b/catalog/app/containers/Admin/Users/gql/UserSetRole.graphql new file mode 100644 index 00000000000..e6da78863d6 --- /dev/null +++ b/catalog/app/containers/Admin/Users/gql/UserSetRole.graphql @@ -0,0 +1,13 @@ +# import UserResultSelection from "./UserResultSelection.graphql" + +mutation ($name: String!, $roleName: String!) { + admin { + user { + mutate(name: $name) { + setRole(roleName: $roleName) { + ...UserResultSelection + } + } + } + } +} diff --git a/catalog/app/containers/Admin/Users/gql/Roles.generated.ts b/catalog/app/containers/Admin/Users/gql/Users.generated.ts similarity index 56% rename from catalog/app/containers/Admin/Users/gql/Roles.generated.ts rename to catalog/app/containers/Admin/Users/gql/Users.generated.ts index 69e8efd8687..80f5a711c7c 100644 --- a/catalog/app/containers/Admin/Users/gql/Roles.generated.ts +++ b/catalog/app/containers/Admin/Users/gql/Users.generated.ts @@ -2,11 +2,23 @@ import type { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/core' import * as Types from '../../../../model/graphql/types.generated' -export type containers_Admin_Users_gql_RolesQueryVariables = Types.Exact<{ +import { + UserSelectionFragment, + UserSelectionFragmentDoc, +} from './UserSelection.generated' + +export type containers_Admin_Users_gql_UsersQueryVariables = Types.Exact<{ [key: string]: never }> -export type containers_Admin_Users_gql_RolesQuery = { readonly __typename: 'Query' } & { +export type containers_Admin_Users_gql_UsersQuery = { readonly __typename: 'Query' } & { + readonly admin: { readonly __typename: 'AdminQueries' } & { + readonly user: { readonly __typename: 'UserAdminQueries' } & { + readonly list: ReadonlyArray< + { readonly __typename: 'User' } & UserSelectionFragment + > + } + } readonly roles: ReadonlyArray< | ({ readonly __typename: 'UnmanagedRole' } & Pick< Types.UnmanagedRole, @@ -15,21 +27,55 @@ export type containers_Admin_Users_gql_RolesQuery = { readonly __typename: 'Quer | ({ readonly __typename: 'ManagedRole' } & Pick) > readonly defaultRole: Types.Maybe< - | ({ readonly __typename: 'UnmanagedRole' } & Pick) - | ({ readonly __typename: 'ManagedRole' } & Pick) + | ({ readonly __typename: 'UnmanagedRole' } & Pick< + Types.UnmanagedRole, + 'id' | 'name' + >) + | ({ readonly __typename: 'ManagedRole' } & Pick) > } -export const containers_Admin_Users_gql_RolesDocument = { +export const containers_Admin_Users_gql_UsersDocument = { kind: 'Document', definitions: [ { kind: 'OperationDefinition', operation: 'query', - name: { kind: 'Name', value: 'containers_Admin_Users_gql_Roles' }, + name: { kind: 'Name', value: 'containers_Admin_Users_gql_Users' }, selectionSet: { kind: 'SelectionSet', selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'admin' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'user' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'list' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'FragmentSpread', + name: { kind: 'Name', value: 'UserSelection' }, + }, + ], + }, + }, + ], + }, + }, + ], + }, + }, { kind: 'Field', name: { kind: 'Name', value: 'roles' }, @@ -81,7 +127,10 @@ export const containers_Admin_Users_gql_RolesDocument = { }, selectionSet: { kind: 'SelectionSet', - selections: [{ kind: 'Field', name: { kind: 'Name', value: 'id' } }], + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'id' } }, + { kind: 'Field', name: { kind: 'Name', value: 'name' } }, + ], }, }, { @@ -92,7 +141,10 @@ export const containers_Admin_Users_gql_RolesDocument = { }, selectionSet: { kind: 'SelectionSet', - selections: [{ kind: 'Field', name: { kind: 'Name', value: 'id' } }], + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'id' } }, + { kind: 'Field', name: { kind: 'Name', value: 'name' } }, + ], }, }, ], @@ -101,10 +153,11 @@ export const containers_Admin_Users_gql_RolesDocument = { ], }, }, + ...UserSelectionFragmentDoc.definitions, ], } as unknown as DocumentNode< - containers_Admin_Users_gql_RolesQuery, - containers_Admin_Users_gql_RolesQueryVariables + containers_Admin_Users_gql_UsersQuery, + containers_Admin_Users_gql_UsersQueryVariables > -export { containers_Admin_Users_gql_RolesDocument as default } +export { containers_Admin_Users_gql_UsersDocument as default } diff --git a/catalog/app/containers/Admin/Users/gql/Roles.graphql b/catalog/app/containers/Admin/Users/gql/Users.graphql similarity index 59% rename from catalog/app/containers/Admin/Users/gql/Roles.graphql rename to catalog/app/containers/Admin/Users/gql/Users.graphql index 72109c76ff5..1d87f58c480 100644 --- a/catalog/app/containers/Admin/Users/gql/Roles.graphql +++ b/catalog/app/containers/Admin/Users/gql/Users.graphql @@ -1,4 +1,13 @@ +# import UserSelection from "./UserSelection.graphql" + query { + admin { + user { + list { + ...UserSelection + } + } + } roles { ... on UnmanagedRole { id @@ -12,9 +21,11 @@ query { defaultRole { ... on UnmanagedRole { id + name } ... on ManagedRole { id + name } } } diff --git a/catalog/app/containers/Admin/UsersAndRoles.js b/catalog/app/containers/Admin/UsersAndRoles.tsx similarity index 61% rename from catalog/app/containers/Admin/UsersAndRoles.js rename to catalog/app/containers/Admin/UsersAndRoles.tsx index f7141628385..97cc31cc945 100644 --- a/catalog/app/containers/Admin/UsersAndRoles.js +++ b/catalog/app/containers/Admin/UsersAndRoles.tsx @@ -1,23 +1,18 @@ import * as React from 'react' import * as M from '@material-ui/core' -import * as APIConnector from 'utils/APIConnector' import MetaTitle from 'utils/MetaTitle' -import * as Cache from 'utils/ResourceCache' +// XXX: move the components imported below into a single module import { Roles, Policies } from './RolesAndPolicies' import Users from './Users' -import * as data from './data' export default function UsersAndRoles() { - const req = APIConnector.use() - // TODO: use gql for querying users when implemented - const users = Cache.useData(data.UsersResource, { req }) return ( <> {['Users, Roles and Policies', 'Admin']} - + diff --git a/catalog/app/containers/Admin/data.js b/catalog/app/containers/Admin/data.js deleted file mode 100644 index c1ae65b434f..00000000000 --- a/catalog/app/containers/Admin/data.js +++ /dev/null @@ -1,24 +0,0 @@ -import * as R from 'ramda' - -import * as Cache from 'utils/ResourceCache' - -// TODO: remove after migrating this data to gql -export const UsersResource = Cache.createResource({ - name: 'Admin.data.users', - fetch: ({ req }) => - req({ endpoint: `/users/list?_cachebust=${Math.random()}` }).then( - R.pipe( - R.prop('results'), - R.map((u) => ({ - dateJoined: new Date(u.date_joined), - email: u.email, - isActive: u.is_active, - isAdmin: u.is_superuser, - lastLogin: new Date(u.last_login), - username: u.username, - roleId: u.role_id, - })), - ), - ), - key: () => null, -}) diff --git a/catalog/app/containers/Admin/index.js b/catalog/app/containers/Admin/index.ts similarity index 100% rename from catalog/app/containers/Admin/index.js rename to catalog/app/containers/Admin/index.ts