diff --git a/api/src/graphql/roles.sdl.ts b/api/src/graphql/roles.sdl.ts deleted file mode 100644 index bba4595a..00000000 --- a/api/src/graphql/roles.sdl.ts +++ /dev/null @@ -1,28 +0,0 @@ -export const schema = gql` - type Role { - id: Int! - name: String! - createdAt: DateTime! - updatedAt: DateTime! - users: [User]! - } - - type Query { - roles: [Role!]! @requireAuth - role(id: Int!): Role @requireAuth - } - - input CreateRoleInput { - name: String! - } - - input UpdateRoleInput { - name: String - } - - type Mutation { - createRole(input: CreateRoleInput!): Role! @requireAuth - updateRole(id: Int!, input: UpdateRoleInput!): Role! @requireAuth - deleteRole(id: Int!): Role! @requireAuth - } -` diff --git a/api/src/graphql/users.sdl.ts b/api/src/graphql/users.sdl.ts index 745c2114..13b2c791 100644 --- a/api/src/graphql/users.sdl.ts +++ b/api/src/graphql/users.sdl.ts @@ -32,19 +32,23 @@ export const schema = gql` email: String! name: String agencyId: Int - role: String + organizationId: Int + role: RoleEnum } input UpdateUserInput { email: String name: String agencyId: Int - role: String + organizationId: Int + role: RoleEnum } type Mutation { - createUser(input: CreateUserInput!): User! @requireAuth - updateUser(id: Int!, input: UpdateUserInput!): User! @requireAuth + createUser(input: CreateUserInput!): User! + @requireAuth(roles: ["USDR_ADMIN", "ORGANIZATION_ADMIN"]) + updateUser(id: Int!, input: UpdateUserInput!): User! + @requireAuth(roles: ["USDR_ADMIN", "ORGANIZATION_ADMIN"]) deleteUser(id: Int!): User! @requireAuth } ` diff --git a/api/src/lib/constants.ts b/api/src/lib/constants.ts new file mode 100644 index 00000000..66fc2e4d --- /dev/null +++ b/api/src/lib/constants.ts @@ -0,0 +1,5 @@ +export const ROLES = { + ORGANIZATION_ADMIN: 'ORGANIZATION_ADMIN', + ORGANIZATION_STAFF: 'ORGANIZATION_STAFF', + USDR_ADMIN: 'USDR_ADMIN', +} diff --git a/api/src/services/users/users.scenarios.ts b/api/src/services/users/users.scenarios.ts index a36be647..e19cc537 100644 --- a/api/src/services/users/users.scenarios.ts +++ b/api/src/services/users/users.scenarios.ts @@ -1,24 +1,41 @@ -import type { Prisma, User } from '@prisma/client' +import type { Prisma, User, Organization, Agency } from '@prisma/client' import type { ScenarioData } from '@redwoodjs/testing/api' -export const standard = defineScenario({ - user: { +export const standard = defineScenario< + | Prisma.OrganizationCreateArgs + | Prisma.AgencyCreateArgs + | Prisma.UserCreateArgs +>({ + organization: { one: { data: { - email: 'String', - updatedAt: '2023-12-10T00:37:26.049Z', - organization: { create: { name: 'String' } }, + name: 'String', }, }, - two: { + }, + agency: { + one: (scenario) => ({ + data: { + name: 'String', + organizationId: scenario.organization.one.id, + code: 'String', + }, + }), + }, + user: { + one: { data: { email: 'String', - updatedAt: '2023-12-10T00:37:26.049Z', + name: 'String', organization: { create: { name: 'String' } }, + agency: { create: { name: 'String', code: 'String' } }, + role: 'ORGANIZATION_ADMIN', }, }, }, }) -export type StandardScenario = ScenarioData +export type StandardScenario = ScenarioData & + ScenarioData & + ScenarioData diff --git a/api/src/services/users/users.test.ts b/api/src/services/users/users.test.ts index 0fafc9c0..0c7fac40 100644 --- a/api/src/services/users/users.test.ts +++ b/api/src/services/users/users.test.ts @@ -23,17 +23,24 @@ describe('users', () => { }) scenario('creates a user', async (scenario: StandardScenario) => { + mockCurrentUser({ + id: scenario.user.one.id, + organizationId: scenario.user.one.organizationId, + email: 'email@example.com', + roles: ['USDR_ADMIN'], + }) + const result = await createUser({ input: { - email: 'String', - organizationId: scenario.user.two.organizationId, - updatedAt: '2023-12-10T00:37:26.029Z', + name: scenario.user.one.name, + email: scenario.user.one.email, + agencyId: scenario.agency.one.id, + role: 'ORGANIZATION_STAFF', }, }) - expect(result.email).toEqual('String') - expect(result.organizationId).toEqual(scenario.user.two.organizationId) - expect(result.updatedAt).toEqual(new Date('2023-12-10T00:37:26.029Z')) + expect(result.email).toEqual(scenario.user.one.email) + expect(result.organizationId).toEqual(scenario.organization.one.id) }) scenario('updates a user', async (scenario: StandardScenario) => { diff --git a/api/src/services/users/users.ts b/api/src/services/users/users.ts index 7276e178..3dc3789d 100644 --- a/api/src/services/users/users.ts +++ b/api/src/services/users/users.ts @@ -4,6 +4,9 @@ import type { UserRelationResolvers, } from 'types/graphql' +import { AuthenticationError } from '@redwoodjs/graphql-server' + +import { ROLES } from 'src/lib/constants' import { db } from 'src/lib/db' export const users: QueryResolvers['users'] = () => { @@ -16,10 +19,55 @@ export const user: QueryResolvers['user'] = ({ id }) => { }) } -export const createUser: MutationResolvers['createUser'] = ({ input }) => { - return db.user.create({ - data: input, - }) +/** + * Determines if the current user can create a new user with the specified role. + * - USDR_ADMIN can create users with any role. + * - ORGANIZATION_ADMIN can create users with roles ORGANIZATION_STAFF or ORGANIZATION_ADMIN. + * - ORGANIZATION_STAFF can't create users. + * + * @param {Object} currentUser - The current user object. + * @param {string} userRoleToCreate - The role of the user to be created. + * @returns {boolean} True if the user can create the new user, false otherwise. + */ +const canCreateUser = (currentUser, userRoleToCreate: string) => { + const { USDR_ADMIN, ORGANIZATION_ADMIN, ORGANIZATION_STAFF } = ROLES + + if (currentUser.roles?.includes(USDR_ADMIN)) { + return true + } + + if (currentUser.roles?.includes(ORGANIZATION_ADMIN)) { + return ( + userRoleToCreate === ORGANIZATION_STAFF || + userRoleToCreate === ORGANIZATION_ADMIN + ) + } + + return false +} + +export const createUser: MutationResolvers['createUser'] = async ({ + input, +}) => { + if (!canCreateUser(context.currentUser, input.role)) { + throw new AuthenticationError("You don't have permission to do that.") + } + + const { agencyId } = input + + try { + const agency = await db.agency.findUnique({ where: { id: agencyId } }) + + if (!agency) { + throw new Error('Agency not found.') + } + + return db.user.create({ + data: { ...input, organizationId: agency.organizationId }, + }) + } catch (err) { + throw new Error(err) + } } export const updateUser: MutationResolvers['updateUser'] = ({ id, input }) => { diff --git a/api/types/graphql.d.ts b/api/types/graphql.d.ts index 1c840813..97cee366 100644 --- a/api/types/graphql.d.ts +++ b/api/types/graphql.d.ts @@ -115,10 +115,6 @@ export type CreateReportingPeriodInput = { startDate: Scalars['DateTime']; }; -export type CreateRoleInput = { - name: Scalars['String']; -}; - export type CreateSubrecipientInput = { certifiedAt?: InputMaybe; certifiedById?: InputMaybe; @@ -156,7 +152,8 @@ export type CreateUserInput = { agencyId?: InputMaybe; email: Scalars['String']; name?: InputMaybe; - role?: InputMaybe; + organizationId?: InputMaybe; + role?: InputMaybe; }; export type ExpenditureCategory = { @@ -191,7 +188,6 @@ export type Mutation = { createOutputTemplate: OutputTemplate; createProject: Project; createReportingPeriod: ReportingPeriod; - createRole: Role; createSubrecipient: Subrecipient; createUpload: Upload; createUploadValidation: UploadValidation; @@ -203,7 +199,6 @@ export type Mutation = { deleteOutputTemplate: OutputTemplate; deleteProject: Project; deleteReportingPeriod: ReportingPeriod; - deleteRole: Role; deleteSubrecipient: Subrecipient; deleteUpload: Upload; deleteUploadValidation: UploadValidation; @@ -215,7 +210,6 @@ export type Mutation = { updateOutputTemplate: OutputTemplate; updateProject: Project; updateReportingPeriod: ReportingPeriod; - updateRole: Role; updateSubrecipient: Subrecipient; updateUpload: Upload; updateUploadValidation: UploadValidation; @@ -263,11 +257,6 @@ export type MutationcreateReportingPeriodArgs = { }; -export type MutationcreateRoleArgs = { - input: CreateRoleInput; -}; - - export type MutationcreateSubrecipientArgs = { input: CreateSubrecipientInput; }; @@ -323,11 +312,6 @@ export type MutationdeleteReportingPeriodArgs = { }; -export type MutationdeleteRoleArgs = { - id: Scalars['Int']; -}; - - export type MutationdeleteSubrecipientArgs = { id: Scalars['Int']; }; @@ -390,12 +374,6 @@ export type MutationupdateReportingPeriodArgs = { }; -export type MutationupdateRoleArgs = { - id: Scalars['Int']; - input: UpdateRoleInput; -}; - - export type MutationupdateSubrecipientArgs = { id: Scalars['Int']; input: UpdateSubrecipientInput; @@ -481,8 +459,6 @@ export type Query = { redwood?: Maybe; reportingPeriod?: Maybe; reportingPeriods: Array; - role?: Maybe; - roles: Array; subrecipient?: Maybe; subrecipients: Array; upload?: Maybe; @@ -543,12 +519,6 @@ export type QueryreportingPeriodArgs = { }; -/** About the Redwood queries. */ -export type QueryroleArgs = { - id: Scalars['Int']; -}; - - /** About the Redwood queries. */ export type QuerysubrecipientArgs = { id: Scalars['Int']; @@ -615,15 +585,6 @@ export type ReportingPeriod = { uploads: Array>; }; -export type Role = { - __typename?: 'Role'; - createdAt: Scalars['DateTime']; - id: Scalars['Int']; - name: Scalars['String']; - updatedAt: Scalars['DateTime']; - users: Array>; -}; - export type RoleEnum = | 'ORGANIZATION_ADMIN' | 'ORGANIZATION_STAFF' @@ -698,10 +659,6 @@ export type UpdateReportingPeriodInput = { startDate?: InputMaybe; }; -export type UpdateRoleInput = { - name?: InputMaybe; -}; - export type UpdateSubrecipientInput = { certifiedAt?: InputMaybe; certifiedById?: InputMaybe; @@ -739,7 +696,8 @@ export type UpdateUserInput = { agencyId?: InputMaybe; email?: InputMaybe; name?: InputMaybe; - role?: InputMaybe; + organizationId?: InputMaybe; + role?: InputMaybe; }; export type Upload = { @@ -879,7 +837,6 @@ export type ResolversTypes = { CreateOutputTemplateInput: CreateOutputTemplateInput; CreateProjectInput: CreateProjectInput; CreateReportingPeriodInput: CreateReportingPeriodInput; - CreateRoleInput: CreateRoleInput; CreateSubrecipientInput: CreateSubrecipientInput; CreateUploadInput: CreateUploadInput; CreateUploadValidationInput: CreateUploadValidationInput; @@ -898,7 +855,6 @@ export type ResolversTypes = { Query: ResolverTypeWrapper<{}>; Redwood: ResolverTypeWrapper; ReportingPeriod: ResolverTypeWrapper, AllMappedModels>>; - Role: ResolverTypeWrapper & { users: Array> }>; RoleEnum: RoleEnum; String: ResolverTypeWrapper; Subrecipient: ResolverTypeWrapper, AllMappedModels>>; @@ -910,7 +866,6 @@ export type ResolversTypes = { UpdateOutputTemplateInput: UpdateOutputTemplateInput; UpdateProjectInput: UpdateProjectInput; UpdateReportingPeriodInput: UpdateReportingPeriodInput; - UpdateRoleInput: UpdateRoleInput; UpdateSubrecipientInput: UpdateSubrecipientInput; UpdateUploadInput: UpdateUploadInput; UpdateUploadValidationInput: UpdateUploadValidationInput; @@ -934,7 +889,6 @@ export type ResolversParentTypes = { CreateOutputTemplateInput: CreateOutputTemplateInput; CreateProjectInput: CreateProjectInput; CreateReportingPeriodInput: CreateReportingPeriodInput; - CreateRoleInput: CreateRoleInput; CreateSubrecipientInput: CreateSubrecipientInput; CreateUploadInput: CreateUploadInput; CreateUploadValidationInput: CreateUploadValidationInput; @@ -953,7 +907,6 @@ export type ResolversParentTypes = { Query: {}; Redwood: Redwood; ReportingPeriod: MergePrismaWithSdlTypes, AllMappedModels>; - Role: Omit & { users: Array> }; String: Scalars['String']; Subrecipient: MergePrismaWithSdlTypes, AllMappedModels>; Time: Scalars['Time']; @@ -964,7 +917,6 @@ export type ResolversParentTypes = { UpdateOutputTemplateInput: UpdateOutputTemplateInput; UpdateProjectInput: UpdateProjectInput; UpdateReportingPeriodInput: UpdateReportingPeriodInput; - UpdateRoleInput: UpdateRoleInput; UpdateSubrecipientInput: UpdateSubrecipientInput; UpdateUploadInput: UpdateUploadInput; UpdateUploadValidationInput: UpdateUploadValidationInput; @@ -1089,7 +1041,6 @@ export type MutationResolvers>; createProject: Resolver>; createReportingPeriod: Resolver>; - createRole: Resolver>; createSubrecipient: Resolver>; createUpload: Resolver>; createUploadValidation: Resolver>; @@ -1101,7 +1052,6 @@ export type MutationResolvers>; deleteProject: Resolver>; deleteReportingPeriod: Resolver>; - deleteRole: Resolver>; deleteSubrecipient: Resolver>; deleteUpload: Resolver>; deleteUploadValidation: Resolver>; @@ -1113,7 +1063,6 @@ export type MutationResolvers>; updateProject: Resolver>; updateReportingPeriod: Resolver>; - updateRole: Resolver>; updateSubrecipient: Resolver>; updateUpload: Resolver>; updateUploadValidation: Resolver>; @@ -1129,7 +1078,6 @@ export type MutationRelationResolvers>; createProject?: RequiredResolverFn>; createReportingPeriod?: RequiredResolverFn>; - createRole?: RequiredResolverFn>; createSubrecipient?: RequiredResolverFn>; createUpload?: RequiredResolverFn>; createUploadValidation?: RequiredResolverFn>; @@ -1141,7 +1089,6 @@ export type MutationRelationResolvers>; deleteProject?: RequiredResolverFn>; deleteReportingPeriod?: RequiredResolverFn>; - deleteRole?: RequiredResolverFn>; deleteSubrecipient?: RequiredResolverFn>; deleteUpload?: RequiredResolverFn>; deleteUploadValidation?: RequiredResolverFn>; @@ -1153,7 +1100,6 @@ export type MutationRelationResolvers>; updateProject?: RequiredResolverFn>; updateReportingPeriod?: RequiredResolverFn>; - updateRole?: RequiredResolverFn>; updateSubrecipient?: RequiredResolverFn>; updateUpload?: RequiredResolverFn>; updateUploadValidation?: RequiredResolverFn>; @@ -1261,8 +1207,6 @@ export type QueryResolvers, ParentType, ContextType>; reportingPeriod: Resolver, ParentType, ContextType, RequireFields>; reportingPeriods: OptArgsResolverFn, ParentType, ContextType>; - role: Resolver, ParentType, ContextType, RequireFields>; - roles: OptArgsResolverFn, ParentType, ContextType>; subrecipient: Resolver, ParentType, ContextType, RequireFields>; subrecipients: OptArgsResolverFn, ParentType, ContextType>; upload: Resolver, ParentType, ContextType, RequireFields>; @@ -1291,8 +1235,6 @@ export type QueryRelationResolvers, ParentType, ContextType>; reportingPeriod?: RequiredResolverFn, ParentType, ContextType, RequireFields>; reportingPeriods?: RequiredResolverFn, ParentType, ContextType>; - role?: RequiredResolverFn, ParentType, ContextType, RequireFields>; - roles?: RequiredResolverFn, ParentType, ContextType>; subrecipient?: RequiredResolverFn, ParentType, ContextType, RequireFields>; subrecipients?: RequiredResolverFn, ParentType, ContextType>; upload?: RequiredResolverFn, ParentType, ContextType, RequireFields>; @@ -1362,24 +1304,6 @@ export type ReportingPeriodRelationResolvers; }; -export type RoleResolvers = { - createdAt: OptArgsResolverFn; - id: OptArgsResolverFn; - name: OptArgsResolverFn; - updatedAt: OptArgsResolverFn; - users: OptArgsResolverFn>, ParentType, ContextType>; - __isTypeOf?: IsTypeOfResolverFn; -}; - -export type RoleRelationResolvers = { - createdAt?: RequiredResolverFn; - id?: RequiredResolverFn; - name?: RequiredResolverFn; - updatedAt?: RequiredResolverFn; - users?: RequiredResolverFn>, ParentType, ContextType>; - __isTypeOf?: IsTypeOfResolverFn; -}; - export type SubrecipientResolvers = { certifiedAt: OptArgsResolverFn, ParentType, ContextType>; certifiedBy: OptArgsResolverFn, ParentType, ContextType>; @@ -1561,7 +1485,6 @@ export type Resolvers = { Query: QueryResolvers; Redwood: RedwoodResolvers; ReportingPeriod: ReportingPeriodResolvers; - Role: RoleResolvers; Subrecipient: SubrecipientResolvers; Time: GraphQLScalarType; Upload: UploadResolvers; diff --git a/web/src/Routes.tsx b/web/src/Routes.tsx index c9d803f9..e748d4d9 100644 --- a/web/src/Routes.tsx +++ b/web/src/Routes.tsx @@ -28,10 +28,12 @@ const Routes = () => { {/* Users */} - - - - + + + + + + {/* Reporting Periods */} diff --git a/web/src/components/Navigation/Navigation.tsx b/web/src/components/Navigation/Navigation.tsx index 4d232d49..ec337921 100644 --- a/web/src/components/Navigation/Navigation.tsx +++ b/web/src/components/Navigation/Navigation.tsx @@ -1,10 +1,13 @@ +import { ROLES } from 'api/src/lib/constants' import Nav from 'react-bootstrap/Nav' import { useAuth } from 'web/src/auth' import { NavLink, routes } from '@redwoodjs/router' const Navigation = () => { - const { currentUser } = useAuth() + const { hasRole } = useAuth() + const canViewUsersTab = + hasRole(ROLES.USDR_ADMIN) || hasRole(ROLES.ORGANIZATION_ADMIN) return (
@@ -40,15 +43,17 @@ const Navigation = () => { Subrecipients */} - - - Users - - + {canViewUsersTab && ( + + + Users + + + )} { Reporting Periods - {currentUser?.roles?.includes('USDR_ADMIN') && ( + {hasRole(ROLES.USDR_ADMIN) && ( ) => { } return ( -
-
-

- Edit User {user?.id} -

+
+
+

Edit User {user?.id}

-
+
diff --git a/web/src/components/User/NewUser/NewUser.tsx b/web/src/components/User/NewUser/NewUser.tsx index 150bea77..96dd1c8a 100644 --- a/web/src/components/User/NewUser/NewUser.tsx +++ b/web/src/components/User/NewUser/NewUser.tsx @@ -30,11 +30,11 @@ const NewUser = () => { } return ( -
-
-

New User

+
+
+

New User

-
+
diff --git a/web/src/components/User/UserForm/UserForm.tsx b/web/src/components/User/UserForm/UserForm.tsx index 0d74089e..7edb4eb1 100644 --- a/web/src/components/User/UserForm/UserForm.tsx +++ b/web/src/components/User/UserForm/UserForm.tsx @@ -1,18 +1,20 @@ +import { ROLES } from 'api/src/lib/constants' import { Button } from 'react-bootstrap' import { useForm, UseFormReturn } from 'react-hook-form' import type { EditUserById, UpdateUserInput } from 'types/graphql' +import { useAuth } from 'web/src/auth' +import type { RWGqlError } from '@redwoodjs/forms' import { Form, FormError, FieldError, Label, TextField, - NumberField, SelectField, Submit, } from '@redwoodjs/forms' -import type { RWGqlError } from '@redwoodjs/forms' +import { useQuery } from '@redwoodjs/web' type FormUser = NonNullable @@ -23,20 +25,44 @@ interface UserFormProps { loading: boolean } +const GET_AGENCIES_UNDER_USER_ORGANIZATION = gql` + query agenciesUnderUserOrganization($organizationId: Int!) { + agenciesByOrganization(organizationId: $organizationId) { + id + name + } + } +` + const UserForm = (props: UserFormProps) => { + const { hasRole, currentUser } = useAuth() + const { user, onSave, error, loading } = props const formMethods: UseFormReturn = useForm() const hasErrors = Object.keys(formMethods.formState.errors).length > 0 + const { + loading: agenciesLoading, + error: agenciesError, + data: agenciesData, + } = useQuery(GET_AGENCIES_UNDER_USER_ORGANIZATION, { + variables: { organizationId: currentUser.organizationId }, + }) + const agencies = agenciesData?.agenciesByOrganization + // Resets the form to the previous values when editing the existing user // Clears out the form when creating a new user const onReset = () => { formMethods.reset() } + const onSubmit = (data: FormUser) => { onSave(data, props?.user?.id) } + if (agenciesLoading) return
Loading...
+ if (agenciesError) return
Couldn't load agencies
+ return ( onSubmit={onSubmit} @@ -44,6 +70,12 @@ const UserForm = (props: UserFormProps) => { error={error} className={hasErrors ? 'was-validated' : ''} > + {user && (
)} - -
- - + + {hasRole(ROLES.USDR_ADMIN) && ( + + )} @@ -149,18 +180,30 @@ const UserForm = (props: UserFormProps) => {
- + className="form-select" + validation={{ + required: 'This field is required', + valueAsNumber: true, + }} + emptyAs={null} + defaultValue={user?.agencyId} + errorClassName="form-select is-invalid" + > + + {agencies?.map((agency) => ( + + ))} +
{ return ( - +
- - - - - - + + + + + + {usersByOrganization.map((user) => ( - - - - - - + + + + + ))} diff --git a/web/src/components/User/UsersCell/UsersCell.tsx b/web/src/components/User/UsersCell/UsersCell.tsx index bc13cf0c..56350fa4 100644 --- a/web/src/components/User/UsersCell/UsersCell.tsx +++ b/web/src/components/User/UsersCell/UsersCell.tsx @@ -11,7 +11,9 @@ export const QUERY = gql` id email name - agencyId + agency { + name + } organizationId role createdAt diff --git a/web/src/layouts/AuthenticatedLayout/AuthenticatedLayout.tsx b/web/src/layouts/AuthenticatedLayout/AuthenticatedLayout.tsx index 6c954bcb..fe63702d 100644 --- a/web/src/layouts/AuthenticatedLayout/AuthenticatedLayout.tsx +++ b/web/src/layouts/AuthenticatedLayout/AuthenticatedLayout.tsx @@ -23,7 +23,7 @@ const AuthenticatedLayout = ({ children }: AuthenticatedLayoutProps) => {
{isAuthenticated && ( <> -
{currentUser.email}
+
{currentUser?.email as string}
EmailNameAgency idRoleCreated atActionsEmailNameRoleAgencyCreated atActions
{truncate(user.email)}{truncate(user.name)} - {truncate(user.agencyId)} - {formatEnum(user.role)} - {timeTag(user.createdAt)} - - + {truncate(user.email)}{truncate(user.name)}{formatEnum(user.role)}{truncate(user.agency?.name)}{timeTag(user.createdAt)} + + +