diff --git a/app/components/UserLookup.tsx b/app/components/UserLookup.tsx new file mode 100644 index 000000000..c1e041cc3 --- /dev/null +++ b/app/components/UserLookup.tsx @@ -0,0 +1,175 @@ +/*! + * Copyright © 2023 United States Government as represented by the + * Administrator of the National Aeronautics and Space Administration. + * All Rights Reserved. + * + * SPDX-License-Identifier: Apache-2.0 + */ +import { useFetcher } from '@remix-run/react' +import type { action } from 'app/routes/api.users' +import classnames from 'classnames' +import type { UseComboboxProps, UseComboboxStateChange } from 'downshift' +import { useCombobox } from 'downshift' +import { debounce } from 'lodash' +import { useCallback, useEffect, useState } from 'react' + +import { formatAuthor } from '~/routes/circulars/circulars.lib' + +import loaderImage from 'nasawds/src/img/loader.gif' + +export interface UserLookup { + sub?: string + email: string + name?: string + affiliation?: string +} + +interface UserComboBoxProps + extends Omit< + UseComboboxProps, + 'items' | 'onInputValueChange' | 'itemToString' + > { + disabled?: boolean + className?: string + group?: string + onChange?: () => void +} + +interface UserComboBoxHandle { + reset?: () => void +} + +export function UserLookupComboBox({ + disabled, + className, + group, + onChange, + // ref, + ...props +}: UserComboBoxProps & UserComboBoxHandle) { + const fetcher = useFetcher() + const [items, setItems] = useState([]) + + useEffect(() => { + setItems(fetcher.data?.users ?? []) + }, [fetcher.data]) + + if (onChange) onChange() + + // eslint-disable-next-line react-hooks/exhaustive-deps + const onInputValueChange = useCallback( + debounce( + ({ inputValue, isOpen }: UseComboboxStateChange) => { + if (inputValue && isOpen) { + const data = new FormData() + data.set('filter', inputValue.split(' ')[0]) + data.set('intent', 'filter') + data.set('group', group ?? '') + fetcher.submit(data, { method: 'POST', action: '/api/users' }) + } else { + setItems([]) + } + }, + 500, + { trailing: true } + ), + [] + ) + + const { + reset, + isOpen, + highlightedIndex, + selectedItem, + getMenuProps, + getInputProps, + getItemProps, + getToggleButtonProps, + } = useCombobox({ + items, + onInputValueChange, + itemToString(item) { + return item ? formatAuthor(item) : '' + }, + ...props, + }) + + const loading = fetcher.state === 'submitting' + const pristine = Boolean(selectedItem) + + return ( +
+ + + + +   + + + + +
+ ) +} diff --git a/app/email-incoming/circulars/index.ts b/app/email-incoming/circulars/index.ts index bf77c44bc..a02c65933 100644 --- a/app/email-incoming/circulars/index.ts +++ b/app/email-incoming/circulars/index.ts @@ -26,7 +26,7 @@ import { } from '~/lib/cognito.server' import { sendEmail } from '~/lib/email.server' import { hostname, origin } from '~/lib/env.server' -import { group, putRaw } from '~/routes/circulars/circulars.server' +import { putRaw, submitterGroup } from '~/routes/circulars/circulars.server' interface UserData { email: string @@ -120,7 +120,7 @@ export const handler = createEmailIncomingMessageHandler( async function getCognitoUserData( userEmail: string ): Promise { - const users = await listUsersInGroup(group) + const users = await listUsersInGroup(submitterGroup) const Attributes = users.find( ({ Attributes }) => extractAttributeRequired(Attributes, 'email') == userEmail diff --git a/app/lib/cognito.server.ts b/app/lib/cognito.server.ts index 4bfbbba8c..164e1cb1a 100644 --- a/app/lib/cognito.server.ts +++ b/app/lib/cognito.server.ts @@ -13,17 +13,23 @@ import type { } from '@aws-sdk/client-cognito-identity-provider' import { AdminAddUserToGroupCommand, + AdminGetUserCommand, AdminRemoveUserFromGroupCommand, CognitoIdentityProviderClient, CreateGroupCommand, DeleteGroupCommand, GetGroupCommand, + ListUsersCommand, UpdateGroupCommand, paginateAdminListGroupsForUser, paginateListGroups, + paginateListUsers, paginateListUsersInGroup, } from '@aws-sdk/client-cognito-identity-provider' +import type { User } from '~/routes/_auth/user.server' + +export const gcnGroupPrefix = 'gcn.nasa.gov/' export const cognito = new CognitoIdentityProviderClient({}) const UserPoolId = process.env.COGNITO_USER_POOL_ID @@ -58,6 +64,68 @@ export function extractAttributeRequired( return value } +/** + * Gets another user from cognito + * + * @param sub - the sub of another user + * @returns a user if found, otherwise undefined + */ +export async function getCognitoUserFromSub(sub: string) { + const escapedSub = sub.replaceAll('"', '\\"') + const user = ( + await cognito.send( + new ListUsersCommand({ + UserPoolId, + Filter: `sub = "${escapedSub}"`, + }) + ) + )?.Users?.[0] + + if (!user?.Username) + throw new Response('Requested user does not exist', { + status: 400, + }) + + return user +} + +export async function listUsers(filterString: string) { + const pages = paginateListUsers( + { client: cognito }, + { + UserPoolId, + } + ) + const users: Omit[] = [] + for await (const page of pages) { + const nextUsers = page.Users + if (nextUsers) + users.push( + ...nextUsers + .filter( + (user) => + Boolean(extractAttribute(user.Attributes, 'email')) && + (extractAttribute(user.Attributes, 'name') + ?.toLowerCase() + .includes(filterString.toLowerCase()) || + extractAttribute(user.Attributes, 'email') + ?.toLowerCase() + .includes(filterString.toLowerCase())) + ) + .map((user) => ({ + sub: extractAttributeRequired(user.Attributes, 'sub'), + email: extractAttributeRequired(user.Attributes, 'email'), + name: extractAttribute(user.Attributes, 'name'), + affiliation: extractAttribute( + user.Attributes, + 'custom:affiliation' + ), + })) + ) + } + return users +} + export async function listUsersInGroup(GroupName: string) { console.warn( 'using a paginator; replace with alternative API calls that avoid large result sets' @@ -116,7 +184,14 @@ export async function getGroups() { const groups: GroupType[] = [] for await (const page of pages) { const nextGroups = page.Groups - if (nextGroups) groups.push(...nextGroups) + if (nextGroups) + groups.push( + ...nextGroups.filter( + (group) => + group.GroupName !== undefined && + group.GroupName.startsWith(gcnGroupPrefix) + ) + ) } return groups @@ -139,7 +214,8 @@ export async function deleteGroup(GroupName: string) { await cognito.send(command) } -export async function addUserToGroup(Username: string, GroupName: string) { +export async function addUserToGroup(sub: string, GroupName: string) { + const { Username } = await getCognitoUserFromSub(sub) const command = new AdminAddUserToGroupCommand({ UserPoolId, Username, @@ -148,7 +224,8 @@ export async function addUserToGroup(Username: string, GroupName: string) { await cognito.send(command) } -export async function listGroupsForUser(Username: string) { +export async function listGroupsForUser(sub: string) { + const { Username } = await getCognitoUserFromSub(sub) const pages = paginateAdminListGroupsForUser( { client: cognito }, { UserPoolId, Username } @@ -162,7 +239,27 @@ export async function listGroupsForUser(Username: string) { return groups } -export async function removeUserFromGroup(Username: string, GroupName: string) { +export async function getUserGroupStrings(Username: string) { + const Groups = await listGroupsForUser(Username) + return Groups?.map(({ GroupName }) => GroupName).filter(Boolean) as + | string[] + | undefined +} + +export async function checkUserIsVerified(sub: string) { + const { Username } = await getCognitoUserFromSub(sub) + + const { UserAttributes } = await cognito.send( + new AdminGetUserCommand({ + UserPoolId, + Username, + }) + ) + return extractAttribute(UserAttributes, 'email_verified') +} + +export async function removeUserFromGroup(sub: string, GroupName: string) { + const { Username } = await getCognitoUserFromSub(sub) const command = new AdminRemoveUserFromGroupCommand({ UserPoolId, Username, diff --git a/app/root.tsx b/app/root.tsx index 60b43e296..10ac78d99 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -52,7 +52,10 @@ import NewsBanner from './root/NewsBanner' import { type BreadcrumbHandle, Title } from './root/Title' import { Header } from './root/header/Header' import { getUser } from './routes/_auth/user.server' -import { group, moderatorGroup } from './routes/circulars/circulars.server' +import { + moderatorGroup, + submitterGroup, +} from './routes/circulars/circulars.server' import highlightStyle from 'highlight.js/styles/github.css' // FIXME: no top-level await, no import function @@ -115,7 +118,7 @@ export async function loader({ request }: LoaderFunctionArgs) { const idp = user?.idp const recaptchaSiteKey = getEnvOrDieInProduction('RECAPTCHA_SITE_KEY') const userIsMod = user?.groups.includes(moderatorGroup) - const userIsVerifiedSubmitter = user?.groups.includes(group) + const userIsVerifiedSubmitter = user?.groups.includes(submitterGroup) return { origin, diff --git a/app/routes/admin.tsx b/app/routes/admin.tsx new file mode 100644 index 000000000..a38586307 --- /dev/null +++ b/app/routes/admin.tsx @@ -0,0 +1,42 @@ +/*! + * Copyright © 2023 United States Government as represented by the + * Administrator of the National Aeronautics and Space Administration. + * All Rights Reserved. + * + * SPDX-License-Identifier: Apache-2.0 + */ +import type { LoaderFunctionArgs } from '@remix-run/node' +import { NavLink, Outlet } from '@remix-run/react' +import { GridContainer, SideNav } from '@trussworks/react-uswds' + +import { getUser } from './_auth/user.server' + +export const adminGroup = 'gcn.nasa.gov/gcn-admin' + +export async function loader({ request }: LoaderFunctionArgs) { + const user = await getUser(request) + if (!user?.groups.includes(adminGroup)) + throw new Response(null, { status: 403 }) + return null +} + +export default function () { + return ( + +
+
+ + Users + , + ]} + /> +
+
+ +
+
+
+ ) +} diff --git a/app/routes/admin.users.$userId.tsx b/app/routes/admin.users.$userId.tsx new file mode 100644 index 000000000..e6b34d5c1 --- /dev/null +++ b/app/routes/admin.users.$userId.tsx @@ -0,0 +1,129 @@ +/*! + * Copyright © 2023 United States Government as represented by the + * Administrator of the National Aeronautics and Space Administration. + * All Rights Reserved. + * + * SPDX-License-Identifier: Apache-2.0 + */ +import type { ActionFunctionArgs, LoaderFunctionArgs } from '@remix-run/node' +import { useFetcher, useLoaderData } from '@remix-run/react' +import { Button, Checkbox } from '@trussworks/react-uswds' + +import { getUser } from './_auth/user.server' +import { adminGroup } from './admin' +import { + addUserToGroup, + gcnGroupPrefix, + getCognitoUserFromSub, + getGroups, + listGroupsForUser, + removeUserFromGroup, +} from '~/lib/cognito.server' + +interface GroupSelectionItem { + groupName: string + defaultChecked: boolean + description: string +} + +export async function loader({ + params: { userId }, + request, +}: LoaderFunctionArgs) { + const currentUser = await getUser(request) + if (!currentUser?.groups.includes(adminGroup)) + throw new Response(null, { status: 403 }) + if (!userId) throw new Response(null, { status: 404 }) + const user = await getCognitoUserFromSub(userId) + const userGroups = (await listGroupsForUser(userId)).map( + (group) => group.GroupName + ) + const allGroups: GroupSelectionItem[] = (await getGroups()) + .map((x) => { + return { + groupName: x.GroupName ?? '', + defaultChecked: userGroups.includes(x.GroupName), + description: x.Description ?? '', + } + }) + .filter((x) => Boolean(x.groupName)) + return { user, allGroups } +} + +export async function action({ + request, + params: { userId }, +}: ActionFunctionArgs) { + const user = await getUser(request) + if (!user?.groups.includes(adminGroup)) + throw new Response(null, { status: 403 }) + const data = await request.formData() + const { ...selectedGroups } = Object.fromEntries(data) + if (!userId) throw new Response(null, { status: 400 }) + const currentUserGroups = (await listGroupsForUser(userId)).map( + (x) => x.GroupName + ) as string[] + const selectedGroupsNames = Object.keys(selectedGroups) + + await Promise.all([ + ...selectedGroupsNames + .filter( + (x) => x.startsWith(gcnGroupPrefix) && !currentUserGroups.includes(x) + ) + .map((x) => addUserToGroup(userId, x)), + ...currentUserGroups + .filter( + (x) => x.startsWith(gcnGroupPrefix) && !selectedGroupsNames.includes(x) + ) + .map((x) => removeUserFromGroup(userId, x)), + ]) + + return null +} + +export default function () { + const { user, allGroups } = useLoaderData() + const fetcher = useFetcher() + + return ( + <> +

Manage User Settings

+ {user.Attributes?.find((x) => x.Name == 'email')?.Value} + +

Groups

+ {allGroups.map(({ groupName, description, defaultChecked }) => ( +
+ +
+ ))} + +
+ + ) +} + +function GroupsCheckbox({ + groupName, + description, + defaultChecked, +}: { + groupName: string + description: string + defaultChecked: boolean +}) { + return ( + + ) +} diff --git a/app/routes/admin.users._index.tsx b/app/routes/admin.users._index.tsx new file mode 100644 index 000000000..f607ff276 --- /dev/null +++ b/app/routes/admin.users._index.tsx @@ -0,0 +1,52 @@ +/*! + * Copyright © 2023 United States Government as represented by the + * Administrator of the National Aeronautics and Space Administration. + * All Rights Reserved. + * + * SPDX-License-Identifier: Apache-2.0 + */ +import type { ActionFunctionArgs, LoaderFunctionArgs } from '@remix-run/node' +import { redirect } from '@remix-run/node' +import { Form } from '@remix-run/react' +import { Button, Label } from '@trussworks/react-uswds' +import { useState } from 'react' + +import { getUser } from './_auth/user.server' +import { adminGroup } from './admin' +import { UserLookupComboBox } from '~/components/UserLookup' +import { getFormDataString } from '~/lib/utils' + +export async function loader({ request }: LoaderFunctionArgs) { + const user = await getUser(request) + if (!user?.groups.includes(adminGroup)) + throw new Response(null, { status: 403 }) + return null +} + +export async function action({ request }: ActionFunctionArgs) { + const data = await request.formData() + const userSub = getFormDataString(data, 'userSub') + return redirect(`/admin/users/${userSub}`) +} + +export default function () { + const [userSub, setUserSub] = useState('') + return ( + <> +

Users

+

Manage users and their group associations

+
+ + + + setUserSub(selectedItem?.sub ?? '') + } + /> + + + + ) +} diff --git a/app/routes/api.circulars.ts b/app/routes/api.circulars.ts index becbdf37e..abd686180 100644 --- a/app/routes/api.circulars.ts +++ b/app/routes/api.circulars.ts @@ -7,7 +7,6 @@ */ import { AdminGetUserCommand, - AdminListGroupsForUserCommand, type AttributeType, } from '@aws-sdk/client-cognito-identity-provider' import type { ActionFunctionArgs } from '@remix-run/node' @@ -16,11 +15,12 @@ import invariant from 'tiny-invariant' import { getOpenIDClient } from './_auth/auth.server' import { type User, parseGroups, parseIdp } from './_auth/user.server' -import { group, put } from './circulars/circulars.server' +import { put, submitterGroup } from './circulars/circulars.server' import { cognito, extractAttribute, extractAttributeRequired, + getUserGroupStrings, } from '~/lib/cognito.server' import { getEnvOrDie } from '~/lib/env.server' @@ -100,18 +100,6 @@ async function getUserAttributes(Username: string) { } } -async function getUserGroups(Username: string) { - const UserPoolId = getEnvOrDie('COGNITO_USER_POOL_ID') - - const { Groups } = await cognito.send( - new AdminListGroupsForUserCommand({ UserPoolId, Username }) - ) - - return Groups?.map(({ GroupName }) => GroupName).filter(Boolean) as - | string[] - | undefined -} - /** * GCN Circulars submission by third parties on behalf of users via an API. * @@ -178,12 +166,12 @@ export async function action({ request }: ActionFunctionArgs) { } = await parseAccessToken(bearer) // Make sure that the access token contains the required scope for this API - if (!scope.split(' ').includes(group)) + if (!scope.split(' ').includes(submitterGroup)) throw new Response('Invalid scope', { status: 403 }) const [{ existingIdp, ...attrs }, groups] = await Promise.all([ getUserAttributes(cognitoUserName), - getUserGroups(cognitoUserName), + getUserGroupStrings(cognitoUserName), ]) if (existingIdp) throw new Response('Wrong IdP', { status: 400 }) diff --git a/app/routes/api.users.ts b/app/routes/api.users.ts new file mode 100644 index 000000000..163456461 --- /dev/null +++ b/app/routes/api.users.ts @@ -0,0 +1,57 @@ +import type { ActionFunctionArgs } from '@remix-run/node' + +import { getUser } from './_auth/user.server' +import { adminGroup } from './admin' +import { submitterGroup } from './circulars/circulars.server' +import type { UserLookup } from '~/components/UserLookup' +import { + checkUserIsVerified, + listUsers, + listUsersInGroup, +} from '~/lib/cognito.server' +import { getFormDataString } from '~/lib/utils' + +// Groups verified users are allowed to search +const filterableGroups = [submitterGroup] + +export async function action({ request }: ActionFunctionArgs) { + const user = await getUser(request) + if (!user) throw new Response(null, { status: 403 }) + const userIsAdmin = user.groups.includes(adminGroup) + const userIsVerified = await checkUserIsVerified(user.sub) + const data = await request.formData() + const filter = getFormDataString(data, 'filter') + const groupFilter = getFormDataString(data, 'group') + let users: UserLookup[] = [] + if (filter?.length) { + if ( + groupFilter && + ((filterableGroups.includes(groupFilter) && userIsVerified) || + userIsAdmin) + ) { + users = (await listUsersInGroup(groupFilter)) + .map((x) => { + return { + sub: x.Attributes?.find((x) => x.Name == 'sub')?.Value, + email: x.Attributes?.find((x) => x.Name == 'email')?.Value ?? '', + name: x.Attributes?.find((x) => x.Name == 'name')?.Value, + affiliation: x.Attributes?.find((x) => x.Name == 'affilitation') + ?.Value, + } + }) + .filter( + ({ name, email }) => + email !== undefined && + (name?.toLowerCase().includes(filter.toLowerCase()) || + email?.toLowerCase().includes(filter.toLowerCase())) + ) + .slice(0, 5) + } else if (userIsAdmin) { + users = await listUsers(filter) + } + } + + return { + users, + } +} diff --git a/app/routes/circulars.correction.$circularId.tsx b/app/routes/circulars.correction.$circularId.tsx index 54942a145..c4979c2a1 100644 --- a/app/routes/circulars.correction.$circularId.tsx +++ b/app/routes/circulars.correction.$circularId.tsx @@ -12,7 +12,7 @@ import { useLoaderData } from '@remix-run/react' import { getUser } from './_auth/user.server' import { CircularEditForm } from './circulars.edit.$circularId/CircularEditForm' import { formatAuthor } from './circulars/circulars.lib' -import { get, group } from './circulars/circulars.server' +import { get, submitterGroup } from './circulars/circulars.server' import type { BreadcrumbHandle } from '~/root/Title' export const handle: BreadcrumbHandle & SEOHandle = { @@ -27,7 +27,8 @@ export async function loader({ if (!circularId) throw new Response(null, { status: 404 }) const user = await getUser(request) - if (!user?.groups.includes(group)) throw new Response(null, { status: 403 }) + if (!user?.groups.includes(submitterGroup)) + throw new Response(null, { status: 403 }) const circular = await get(parseFloat(circularId)) const defaultDateTime = new Date(circular.createdOn ?? 0).toISOString() diff --git a/app/routes/circulars.new.tsx b/app/routes/circulars.new.tsx index f6d4a9d91..7609045ed 100644 --- a/app/routes/circulars.new.tsx +++ b/app/routes/circulars.new.tsx @@ -18,7 +18,7 @@ import { import { getUser } from './_auth/user.server' import { CircularEditForm } from './circulars.edit.$circularId/CircularEditForm' import { formatAuthor } from './circulars/circulars.lib' -import { group } from './circulars/circulars.server' +import { submitterGroup } from './circulars/circulars.server' import { origin } from '~/lib/env.server' import { getCanonicalUrlHeaders, pickHeaders } from '~/lib/headers.server' import { useSearchString } from '~/lib/utils' @@ -34,7 +34,7 @@ export async function loader({ request }: LoaderFunctionArgs) { let isAuthenticated, isAuthorized, formattedAuthor if (user) { isAuthenticated = true - if (user.groups.includes(group)) isAuthorized = true + if (user.groups.includes(submitterGroup)) isAuthorized = true formattedAuthor = formatAuthor(user) } return json( diff --git a/app/routes/circulars/circulars.server.ts b/app/routes/circulars/circulars.server.ts index cd44bf415..363c957a8 100644 --- a/app/routes/circulars/circulars.server.ts +++ b/app/routes/circulars/circulars.server.ts @@ -37,7 +37,7 @@ import { feature, origin } from '~/lib/env.server' // A type with certain keys required. type Require = Omit & Required> -export const group = 'gcn.nasa.gov/circular-submitter' +export const submitterGroup = 'gcn.nasa.gov/circular-submitter' export const moderatorGroup = 'gcn.nasa.gov/circular-moderator' const getDynamoDBAutoIncrement = memoizee( @@ -288,7 +288,7 @@ export async function put( >, user?: User ) { - if (!user?.groups.includes(group)) + if (!user?.groups.includes(submitterGroup)) throw new Response('User is not in the submitters group', { status: 403, }) diff --git a/app/routes/user.endorsements/endorsements.server.ts b/app/routes/user.endorsements/endorsements.server.ts index cf911caf6..f51bbd590 100644 --- a/app/routes/user.endorsements/endorsements.server.ts +++ b/app/routes/user.endorsements/endorsements.server.ts @@ -9,17 +9,17 @@ import { tables } from '@architect/functions' import { AdminAddUserToGroupCommand, AdminListGroupsForUserCommand, - ListUsersCommand, } from '@aws-sdk/client-cognito-identity-provider' import type { DynamoDBDocument } from '@aws-sdk/lib-dynamodb' import { dedent } from 'ts-dedent' import { clearUserToken, getUser } from '../_auth/user.server' -import { group } from '../circulars/circulars.server' +import { submitterGroup } from '../circulars/circulars.server' import { cognito, extractAttribute, extractAttributeRequired, + getCognitoUserFromSub, listUsersInGroup, maybeThrow, } from '~/lib/cognito.server' @@ -65,7 +65,7 @@ export class EndorsementsServer { } userIsSubmitter() { - return this.#currentUserGroups.includes(group) + return this.#currentUserGroups.includes(submitterGroup) } static async create(request: Request) { @@ -96,7 +96,7 @@ export class EndorsementsServer { } ) - const user = await this.#getCognitoUserForSub(endorserSub) + const user = await getCognitoUserFromSub(endorserSub) const { Groups } = await cognito.send( new AdminListGroupsForUserCommand({ @@ -105,7 +105,7 @@ export class EndorsementsServer { }) ) - if (!Groups?.find(({ GroupName }) => GroupName === group)) + if (!Groups?.find(({ GroupName }) => GroupName === submitterGroup)) throw new Response('User is not in the submitters group', { status: 400, }) @@ -347,31 +347,6 @@ export class EndorsementsServer { return users.filter(({ sub }) => !excludedSubs.has(sub)) } - /** - * Gets another user from cognito - * - * @param escapedEndorserString - the sub of another user - * @returns a user if found, otherwise undefined - */ - async #getCognitoUserForSub(sub: string) { - const escapedSub = sub.replaceAll('"', '\\"') - const user = ( - await cognito.send( - new ListUsersCommand({ - UserPoolId: process.env.COGNITO_USER_POOL_ID, - Filter: `sub = "${escapedSub}"`, - }) - ) - )?.Users?.[0] - - if (!user) - throw new Response('Requested user does not exist', { - status: 400, - }) - - return user - } - /** * Adds a user to the circulars submitters group * @@ -381,13 +356,13 @@ export class EndorsementsServer { * @param sub - sub of another user */ async #addUserToGroup(sub: string) { - const { Username } = await this.#getCognitoUserForSub(sub) + const { Username } = await getCognitoUserFromSub(sub) await cognito.send( new AdminAddUserToGroupCommand({ Username, UserPoolId: process.env.COGNITO_USER_POOL_ID, - GroupName: group, + GroupName: submitterGroup, }) ) } @@ -396,7 +371,7 @@ export class EndorsementsServer { async function getUsersInGroup(): Promise { let users try { - users = await listUsersInGroup(group) + users = await listUsersInGroup(submitterGroup) } catch (error) { maybeThrow(error, 'returning fake users') return [ diff --git a/app/routes/user.endorsements/route.tsx b/app/routes/user.endorsements/route.tsx index 776d79ab0..0367e4fa0 100644 --- a/app/routes/user.endorsements/route.tsx +++ b/app/routes/user.endorsements/route.tsx @@ -16,36 +16,16 @@ import { Label, TextInput, } from '@trussworks/react-uswds' -import classnames from 'classnames' -import { - type UseComboboxProps, - type UseComboboxStateChange, - useCombobox, -} from 'downshift' -import debounce from 'lodash/debounce' -import { - forwardRef, - useCallback, - useEffect, - useImperativeHandle, - useRef, - useState, -} from 'react' +import { useEffect, useRef, useState } from 'react' import { useResizeObserver } from 'usehooks-ts' -import { formatAuthor } from '../circulars/circulars.lib' -import type { - EndorsementRequest, - EndorsementRole, - EndorsementUser, -} from './endorsements.server' +import type { EndorsementRequest, EndorsementRole } from './endorsements.server' import { EndorsementsServer } from './endorsements.server' import SegmentedCards from '~/components/SegmentedCards' +import { UserLookupComboBox } from '~/components/UserLookup' import { getFormDataString } from '~/lib/utils' import type { BreadcrumbHandle } from '~/root/Title' -import loaderImage from 'nasawds/src/img/loader.gif' - export const handle: BreadcrumbHandle & SEOHandle = { breadcrumb: 'Peer Endorsements', getSitemapEntries: () => null, @@ -370,155 +350,10 @@ export function EndorsementRequestCard({ ) } -interface EndorsementComboBoxProps - extends Omit< - UseComboboxProps, - 'items' | 'onInputValueChange' | 'itemToString' - > { - disabled?: boolean - className?: string -} - interface EndorsementComboBoxHandle { reset: () => void } -const EndorserComboBox = forwardRef< - EndorsementComboBoxHandle, - EndorsementComboBoxProps ->(({ disabled, className, ...props }, ref) => { - const fetcher = useFetcher() - const [items, setItems] = useState([]) - - useEffect(() => { - setItems(fetcher.data?.submitters ?? []) - }, [fetcher.data]) - - // eslint-disable-next-line react-hooks/exhaustive-deps - const onInputValueChange = useCallback( - debounce( - ({ inputValue, isOpen }: UseComboboxStateChange) => { - if (inputValue && isOpen) { - const data = new FormData() - data.set('filter', inputValue.split(' ')[0]) - data.set('intent', 'filter') - fetcher.submit(data, { method: 'POST' }) - } else { - setItems([]) - } - }, - 500, - { trailing: true } - ), - [] - ) - - const { - reset, - isOpen, - highlightedIndex, - selectedItem, - getMenuProps, - getInputProps, - getItemProps, - getToggleButtonProps, - } = useCombobox({ - items, - onInputValueChange, - itemToString(item) { - return item ? formatAuthor(item) : '' - }, - ...props, - }) - - useImperativeHandle( - ref, - () => ({ - reset, - }), - [reset] - ) - - const loading = fetcher.state === 'submitting' - const pristine = Boolean(selectedItem) - - return ( -
- - - - -   - - - - -
- ) -}) - export function EndorsementRequestForm() { const ref = useRef(null) const [endorserSub, setEndorserSub] = useState('') @@ -540,10 +375,10 @@ export function EndorsementRequestForm() { - setEndorserSub(selectedItem?.sub ?? '') }