Skip to content

Commit

Permalink
Add user group management (#2268)
Browse files Browse the repository at this point in the history
  • Loading branch information
dakota002 authored Aug 6, 2024
1 parent b8707fa commit 0392d5e
Show file tree
Hide file tree
Showing 14 changed files with 587 additions and 233 deletions.
175 changes: 175 additions & 0 deletions app/components/UserLookup.tsx
Original file line number Diff line number Diff line change
@@ -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<UserLookup>,
'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<typeof action>()
const [items, setItems] = useState<UserLookup[]>([])

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<UserLookup>) => {
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<UserLookup>({
items,
onInputValueChange,
itemToString(item) {
return item ? formatAuthor(item) : ''
},
...props,
})

const loading = fetcher.state === 'submitting'
const pristine = Boolean(selectedItem)

return (
<div
data-testid="combo-box"
data-enhanced="true"
className={classnames('usa-combo-box', className, {
'usa-combo-box--pristine': pristine || loading,
})}
>
<input
autoCapitalize="off"
autoComplete="off"
className="usa-combo-box__input"
disabled={disabled}
{...getInputProps()}
// Funky escape sequence is a zero-width character to prevent Safari
// from attempting to autofill the user's own email address, which
// would be triggered by the presence of the string "email" in the
// placeholder.
placeholder="Name or em&#8203;ail address of user"
/>
<span className="usa-combo-box__clear-input__wrapper" tabIndex={-1}>
<button
type="button"
className="usa-combo-box__clear-input"
aria-label="Clear the select contents"
style={
loading ? { backgroundImage: `url('${loaderImage}')` } : undefined
}
onClick={() => reset()}
hidden={(!pristine || disabled) && !loading}
disabled={disabled}
>
&nbsp;
</button>
</span>
<span className="usa-combo-box__input-button-separator">&nbsp;</span>
<span className="usa-combo-box__toggle-list__wrapper" tabIndex={-1}>
<button
type="button"
className="usa-combo-box__toggle-list"
{...getToggleButtonProps()}
>
&nbsp;
</button>
</span>
<ul
{...getMenuProps()}
className="usa-combo-box__list"
role="listbox"
hidden={!isOpen}
>
{isOpen &&
(items.length ? (
items.map((item, index) => (
<li
key={item.sub}
className={classnames('usa-combo-box__list-option', {
'usa-combo-box__list-option--focused':
index === highlightedIndex,
'usa-combo-box__list-option--selected':
selectedItem?.sub === item.sub,
})}
{...getItemProps({ item, index })}
>
{formatAuthor(item)}
</li>
))
) : (
<li className="usa-combo-box__list-option--no-results">
No results found
</li>
))}
</ul>
</div>
)
}
4 changes: 2 additions & 2 deletions app/email-incoming/circulars/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -120,7 +120,7 @@ export const handler = createEmailIncomingMessageHandler(
async function getCognitoUserData(
userEmail: string
): Promise<UserData | undefined> {
const users = await listUsersInGroup(group)
const users = await listUsersInGroup(submitterGroup)
const Attributes = users.find(
({ Attributes }) =>
extractAttributeRequired(Attributes, 'email') == userEmail
Expand Down
105 changes: 101 additions & 4 deletions app/lib/cognito.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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<User, 'idp' | 'cognitoUserName' | 'groups'>[] = []
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'
Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand All @@ -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 }
Expand All @@ -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,
Expand Down
7 changes: 5 additions & 2 deletions app/root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
Loading

0 comments on commit 0392d5e

Please sign in to comment.