From e5f1cb358e50d30f8a922363bc494c1bdd3011a2 Mon Sep 17 00:00:00 2001 From: cbolles Date: Wed, 3 Jan 2024 11:48:30 -0500 Subject: [PATCH 1/5] Add project ID to organization model --- packages/server/schema.gql | 1 + packages/server/src/organization/dtos/create.dto.ts | 11 +++++++++-- .../server/src/organization/organization.model.ts | 4 ++++ .../server/src/organization/organization.resolver.ts | 2 +- 4 files changed, 15 insertions(+), 3 deletions(-) diff --git a/packages/server/schema.gql b/packages/server/schema.gql index 0c4791c4..ac2c4cee 100644 --- a/packages/server/schema.gql +++ b/packages/server/schema.gql @@ -138,6 +138,7 @@ type Mutation { input OrganizationCreate { name: String! + projectId: String! } input DatasetCreate { diff --git a/packages/server/src/organization/dtos/create.dto.ts b/packages/server/src/organization/dtos/create.dto.ts index 799a6f69..00711320 100644 --- a/packages/server/src/organization/dtos/create.dto.ts +++ b/packages/server/src/organization/dtos/create.dto.ts @@ -1,5 +1,12 @@ -import { InputType, OmitType } from '@nestjs/graphql'; +import { InputType, OmitType, Field } from '@nestjs/graphql'; import { Organization } from '../organization.model'; @InputType() -export class OrganizationCreate extends OmitType(Organization, ['_id'] as const, InputType) {} +export class OrganizationCreate extends OmitType(Organization, ['_id'] as const, InputType) { + /** + * This is mirroring the field in `Organization` model, should only be + * available during creation. + */ + @Field() + projectId: string; +} diff --git a/packages/server/src/organization/organization.model.ts b/packages/server/src/organization/organization.model.ts index 382b7fe0..2ec1a897 100644 --- a/packages/server/src/organization/organization.model.ts +++ b/packages/server/src/organization/organization.model.ts @@ -11,6 +11,10 @@ export class Organization { @Prop() @Field() name: string; + + /** Maps the `projectId` in the auth service back to the organization */ + @Prop() + projectId: string; } export type OrganizationDocument = Organization & Document; diff --git a/packages/server/src/organization/organization.resolver.ts b/packages/server/src/organization/organization.resolver.ts index c5c11aea..f34bae36 100644 --- a/packages/server/src/organization/organization.resolver.ts +++ b/packages/server/src/organization/organization.resolver.ts @@ -4,7 +4,7 @@ import { Organization } from './organization.model'; import { OrganizationService } from './organization.service'; import { CreateOrganizationPipe } from './pipes/create.pipe'; import { UseGuards } from '@nestjs/common'; -import { JwtAuthGuard } from 'src/auth/jwt.guard'; +import { JwtAuthGuard } from '../auth/jwt.guard'; @Resolver(() => Organization) export class OrganizationResolver { From 9e211aeb794ba01585f3475f1a9e115a113cf00f Mon Sep 17 00:00:00 2001 From: cbolles Date: Wed, 3 Jan 2024 11:55:26 -0500 Subject: [PATCH 2/5] Add auth URL to organization --- packages/server/schema.gql | 6 ++++++ packages/server/src/organization/organization.model.ts | 4 ++++ 2 files changed, 10 insertions(+) diff --git a/packages/server/schema.gql b/packages/server/schema.gql index ac2c4cee..0f06f13e 100644 --- a/packages/server/schema.gql +++ b/packages/server/schema.gql @@ -5,6 +5,9 @@ type Organization { _id: ID! name: String! + + """URL where the user logs in against""" + authURL: String! } type Dataset { @@ -138,6 +141,9 @@ type Mutation { input OrganizationCreate { name: String! + + """URL where the user logs in against""" + authURL: String! projectId: String! } diff --git a/packages/server/src/organization/organization.model.ts b/packages/server/src/organization/organization.model.ts index 2ec1a897..00463492 100644 --- a/packages/server/src/organization/organization.model.ts +++ b/packages/server/src/organization/organization.model.ts @@ -15,6 +15,10 @@ export class Organization { /** Maps the `projectId` in the auth service back to the organization */ @Prop() projectId: string; + + @Prop() + @Field({ description: 'URL where the user logs in against' }) + authURL: string; } export type OrganizationDocument = Organization & Document; From 52eb108d3a0b2352445839db1f05ffb548ac92d7 Mon Sep 17 00:00:00 2001 From: cbolles Date: Wed, 3 Jan 2024 12:34:33 -0500 Subject: [PATCH 3/5] Handle ability to login in against different organizations --- packages/client/src/graphql/graphql.ts | 32 ++++---- .../graphql/organization/organization.graphql | 7 ++ .../src/graphql/organization/organization.ts | 49 ++++++++++++ packages/client/src/pages/LoginPage.tsx | 79 ++++++++++++++----- 4 files changed, 129 insertions(+), 38 deletions(-) create mode 100644 packages/client/src/graphql/organization/organization.graphql create mode 100644 packages/client/src/graphql/organization/organization.ts diff --git a/packages/client/src/graphql/graphql.ts b/packages/client/src/graphql/graphql.ts index 2792b3c1..1f99c876 100644 --- a/packages/client/src/graphql/graphql.ts +++ b/packages/client/src/graphql/graphql.ts @@ -68,20 +68,16 @@ export type EmailLoginDto = { export type Entry = { __typename?: 'Entry'; _id: Scalars['String']['output']; + contentType: Scalars['String']['output']; creator: Scalars['ID']['output']; dataset: Scalars['ID']['output']; dateCreated: Scalars['DateTime']['output']; entryID: Scalars['String']['output']; - mediaType: Scalars['String']['output']; meta: Scalars['JSON']['output']; organization: Scalars['ID']['output']; -}; - -export type EntryCreate = { - creator: Scalars['ID']['input']; - entryID: Scalars['String']['input']; - mediaType: Scalars['String']['input']; - meta: Scalars['JSON']['input']; + signedUrl: Scalars['String']['output']; + /** Get the number of milliseconds the signed URL is valid for. */ + signedUrlExpiration: Scalars['Float']['output']; }; export type ForgotDto = { @@ -184,7 +180,6 @@ export type Mutation = { completeTag: Scalars['Boolean']['output']; completeUploadSession: UploadResult; createDataset: Dataset; - createEntry: Entry; createInvite: InviteModel; createOrganization: Organization; createProject: ProjectModel; @@ -194,6 +189,7 @@ export type Mutation = { deleteProject: Scalars['Boolean']['output']; deleteStudy: Scalars['Boolean']['output']; forgotPassword: Scalars['Boolean']['output']; + grantOwner: Scalars['Boolean']['output']; lexiconAddEntry: LexiconEntry; /** Remove all entries from a given lexicon */ lexiconClearEntries: Scalars['Boolean']['output']; @@ -201,7 +197,6 @@ export type Mutation = { loginEmail: AccessToken; loginGoogle: AccessToken; loginUsername: AccessToken; - processEntryUploads: Scalars['Boolean']['output']; refresh: AccessToken; resendInvite: InviteModel; resetPassword: Scalars['Boolean']['output']; @@ -211,7 +206,6 @@ export type Mutation = { updateProjectAuthMethods: ProjectModel; updateProjectSettings: ProjectModel; updateUser: UserModel; - uploadEntryCSV: Scalars['Boolean']['output']; }; @@ -270,12 +264,6 @@ export type MutationCreateDatasetArgs = { }; -export type MutationCreateEntryArgs = { - dataset: Scalars['ID']['input']; - entry: EntryCreate; -}; - - export type MutationCreateInviteArgs = { email: Scalars['String']['input']; role?: InputMaybe; @@ -323,6 +311,11 @@ export type MutationForgotPasswordArgs = { }; +export type MutationGrantOwnerArgs = { + targetUser: Scalars['ID']['input']; +}; + + export type MutationLexiconAddEntryArgs = { entry: LexiconAddEntry; }; @@ -404,11 +397,16 @@ export type MutationUpdateUserArgs = { export type Organization = { __typename?: 'Organization'; _id: Scalars['ID']['output']; + /** URL where the user logs in against */ + authURL: Scalars['String']['output']; name: Scalars['String']['output']; }; export type OrganizationCreate = { + /** URL where the user logs in against */ + authURL: Scalars['String']['input']; name: Scalars['String']['input']; + projectId: Scalars['String']['input']; }; export type Project = { diff --git a/packages/client/src/graphql/organization/organization.graphql b/packages/client/src/graphql/organization/organization.graphql new file mode 100644 index 00000000..fb998152 --- /dev/null +++ b/packages/client/src/graphql/organization/organization.graphql @@ -0,0 +1,7 @@ +query getOrganizations { + getOrganizations { + _id, + name, + authURL + } +} diff --git a/packages/client/src/graphql/organization/organization.ts b/packages/client/src/graphql/organization/organization.ts new file mode 100644 index 00000000..d24a8b68 --- /dev/null +++ b/packages/client/src/graphql/organization/organization.ts @@ -0,0 +1,49 @@ +/* Generated File DO NOT EDIT. */ +/* tslint:disable */ +import * as Types from '../graphql'; + +import { gql } from '@apollo/client'; +import * as Apollo from '@apollo/client'; +const defaultOptions = {} as const; +export type GetOrganizationsQueryVariables = Types.Exact<{ [key: string]: never; }>; + + +export type GetOrganizationsQuery = { __typename?: 'Query', getOrganizations: Array<{ __typename?: 'Organization', _id: string, name: string, authURL: string }> }; + + +export const GetOrganizationsDocument = gql` + query getOrganizations { + getOrganizations { + _id + name + authURL + } +} + `; + +/** + * __useGetOrganizationsQuery__ + * + * To run a query within a React component, call `useGetOrganizationsQuery` and pass it any options that fit your needs. + * When your component renders, `useGetOrganizationsQuery` returns an object from Apollo Client that contains loading, error, and data properties + * you can use to render your UI. + * + * @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options; + * + * @example + * const { data, loading, error } = useGetOrganizationsQuery({ + * variables: { + * }, + * }); + */ +export function useGetOrganizationsQuery(baseOptions?: Apollo.QueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useQuery(GetOrganizationsDocument, options); + } +export function useGetOrganizationsLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useLazyQuery(GetOrganizationsDocument, options); + } +export type GetOrganizationsQueryHookResult = ReturnType; +export type GetOrganizationsLazyQueryHookResult = ReturnType; +export type GetOrganizationsQueryResult = Apollo.QueryResult; \ No newline at end of file diff --git a/packages/client/src/pages/LoginPage.tsx b/packages/client/src/pages/LoginPage.tsx index 615e3727..39b1e33e 100644 --- a/packages/client/src/pages/LoginPage.tsx +++ b/packages/client/src/pages/LoginPage.tsx @@ -1,27 +1,57 @@ -import LockOutlinedIcon from '@mui/icons-material/LockOutlined'; -import { Avatar, Box, Container, Link, Typography } from '@mui/material'; -import { FC, useEffect } from 'react'; +import { Box, Container, FormControl, MenuItem, Select, Button, SelectChangeEvent, Typography } from '@mui/material'; +import { FC, useEffect, useState } from 'react'; import { useAuth } from '../context/Auth.context'; import { useNavigate } from 'react-router-dom'; +import { Organization } from '../graphql/graphql'; +import { useGetOrganizationsQuery } from '../graphql/organization/organization'; export const LoginPage: FC = () => { // Construct the Auth URL - const authUrlBase = import.meta.env.VITE_AUTH_LOGIN_URL; - const projectId = import.meta.env.VITE_AUTH_PROJECT_ID; - const redirectUrl = encodeURIComponent(window.location.origin + '/callback'); - const authUrl = `${authUrlBase}/?projectId=${projectId}&redirectUrl=${redirectUrl}`; - const { authenticated } = useAuth(); const navigate = useNavigate(); + const [organization, setOrganization] = useState(null); + const [authURL, setAuthURL] = useState(null); + + // Fetch organizations + const getOrganizationsResults = useGetOrganizationsQuery(); + const [organizations, setOrganizations] = useState([]); + useEffect(() => { + if (getOrganizationsResults.data) { + setOrganizations(getOrganizationsResults.data.getOrganizations); + } + }, [getOrganizationsResults]); + + // Handle if the user is already authenticated useEffect(() => { if (authenticated) { navigate('/'); - } else { - window.location.href = authUrl; } }, []); + const handleOrganizationChange = (event: SelectChangeEvent) => { + const organization = organizations.find((organization) => organization._id == event.target.value); + if (!organization) { + console.error(`Organization with id ${event.target.value} not found`); + return; + } + + setOrganization(organization); + + // Setup the redirect URL + const redirectUrl = encodeURIComponent(window.location.origin + '/callback'); + setAuthURL(`${organization.authURL}&redirectUrl=${redirectUrl}`); + }; + + const loginRedirect = () => { + if (!authURL) { + console.error('Auth URL not set'); + return; + } + + window.location.href = authURL; + }; + return ( { alignItems: 'center' }} > - - - - - Sign In or Sign Up - - - - by following this link - - + Login + + + + ); From d336f160cfa6aa1b0184c19fe3660c3ddf165b83 Mon Sep 17 00:00:00 2001 From: cbolles Date: Wed, 3 Jan 2024 12:48:37 -0500 Subject: [PATCH 4/5] Get organization based on projectId --- packages/server/src/auth/auth.module.ts | 10 ++++--- packages/server/src/auth/jwt.strategy.ts | 27 ++++++++++++++++--- .../src/organization/organization.context.ts | 7 ++--- .../src/organization/organization.module.ts | 3 ++- .../src/organization/organization.service.ts | 4 +++ 5 files changed, 39 insertions(+), 12 deletions(-) diff --git a/packages/server/src/auth/auth.module.ts b/packages/server/src/auth/auth.module.ts index f432923b..b47c497a 100644 --- a/packages/server/src/auth/auth.module.ts +++ b/packages/server/src/auth/auth.module.ts @@ -8,6 +8,7 @@ import { OrganizationModule } from '../organization/organization.module'; import { HttpModule } from '@nestjs/axios'; import { casbinProvider } from './casbin.provider'; import { AuthResolver } from './auth.resolver'; +import { OrganizationService } from '../organization/organization.service'; @Module({ imports: [ @@ -26,7 +27,8 @@ import { AuthResolver } from './auth.resolver'; }; return options; } - }) + }), + forwardRef(() => OrganizationModule) ], providers: [ AuthService, @@ -35,10 +37,10 @@ import { AuthResolver } from './auth.resolver'; AuthResolver, { provide: JwtStrategy, - inject: [AuthService], - useFactory: async (authService: AuthService) => { + inject: [AuthService, OrganizationService], + useFactory: async (authService: AuthService, organizationService: OrganizationService) => { const key = await authService.getPublicKey(); - return new JwtStrategy(key); + return new JwtStrategy(key, organizationService); } } ], diff --git a/packages/server/src/auth/jwt.strategy.ts b/packages/server/src/auth/jwt.strategy.ts index 0e1eb5c9..dea7b567 100644 --- a/packages/server/src/auth/jwt.strategy.ts +++ b/packages/server/src/auth/jwt.strategy.ts @@ -1,11 +1,17 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, BadGatewayException } from '@nestjs/common'; import { PassportStrategy } from '@nestjs/passport'; import { ExtractJwt, Strategy } from 'passport-jwt'; import { TokenPayload } from './user.dto'; +import { OrganizationService } from '../organization/organization.service'; +import {Organization} from 'src/organization/organization.model'; + +interface JwtStrategyValidate extends TokenPayload { + organization: Organization; +} @Injectable() export class JwtStrategy extends PassportStrategy(Strategy) { - constructor(publicKey: string) { + constructor(publicKey: string, private readonly organizationService: OrganizationService) { super({ jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), ignoreExpiration: false, @@ -13,7 +19,20 @@ export class JwtStrategy extends PassportStrategy(Strategy) { }); } - validate(payload: TokenPayload) { - return payload; + /** + * Need to add the organization at this step since the organization is + * queried from the database and not part of the JWT token. This allows + * the organization to then be pulled in via the organization context + */ + async validate(payload: TokenPayload): Promise { + const organization = await this.organizationService.findByProject(payload.projectId); + if (!organization) { + throw new BadGatewayException('Organization not found'); + } + + return { + ...payload, + organization: organization + }; } } diff --git a/packages/server/src/organization/organization.context.ts b/packages/server/src/organization/organization.context.ts index 8a9bc72a..e77bbf30 100644 --- a/packages/server/src/organization/organization.context.ts +++ b/packages/server/src/organization/organization.context.ts @@ -1,6 +1,7 @@ import { createParamDecorator, ExecutionContext } from '@nestjs/common'; +import {GqlExecutionContext} from '@nestjs/graphql'; -// TODO: After users are added in, grab organization from user -export const OrganizationContext = createParamDecorator((_data: unknown, _ctx: ExecutionContext) => { - return { _id: '1', name: 'ASL-LEX' }; +export const OrganizationContext = createParamDecorator((_data: unknown, ctx: ExecutionContext) => { + const gqlCtx = GqlExecutionContext.create(ctx); + return gqlCtx.getContext().req.user.organization; }); diff --git a/packages/server/src/organization/organization.module.ts b/packages/server/src/organization/organization.module.ts index 9427063a..be0c4528 100644 --- a/packages/server/src/organization/organization.module.ts +++ b/packages/server/src/organization/organization.module.ts @@ -7,6 +7,7 @@ import { CreateOrganizationPipe } from './pipes/create.pipe'; @Module({ imports: [MongooseModule.forFeature([{ name: Organization.name, schema: OrganizationSchema }])], - providers: [OrganizationResolver, OrganizationService, CreateOrganizationPipe] + providers: [OrganizationResolver, OrganizationService, CreateOrganizationPipe], + exports: [OrganizationService] }) export class OrganizationModule {} diff --git a/packages/server/src/organization/organization.service.ts b/packages/server/src/organization/organization.service.ts index 722f57a4..8140a174 100644 --- a/packages/server/src/organization/organization.service.ts +++ b/packages/server/src/organization/organization.service.ts @@ -23,4 +23,8 @@ export class OrganizationService { async findByName(name: string): Promise { return this.orgModel.findOne({ name }); } + + async findByProject(projectId: string): Promise { + return this.orgModel.findOne({ projectId }); + } } From 9068aa4c3fc67b9bfe7d699253aa2e0d1fef2f14 Mon Sep 17 00:00:00 2001 From: cbolles Date: Wed, 3 Jan 2024 12:50:55 -0500 Subject: [PATCH 5/5] Fix formatting --- packages/client/src/pages/LoginPage.tsx | 4 ++-- packages/server/src/auth/jwt.strategy.ts | 2 +- packages/server/src/organization/organization.context.ts | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/client/src/pages/LoginPage.tsx b/packages/client/src/pages/LoginPage.tsx index 39b1e33e..6abdbce3 100644 --- a/packages/client/src/pages/LoginPage.tsx +++ b/packages/client/src/pages/LoginPage.tsx @@ -62,7 +62,7 @@ export const LoginPage: FC = () => { alignItems: 'center' }} > - Login + Login - diff --git a/packages/server/src/auth/jwt.strategy.ts b/packages/server/src/auth/jwt.strategy.ts index dea7b567..360dfd05 100644 --- a/packages/server/src/auth/jwt.strategy.ts +++ b/packages/server/src/auth/jwt.strategy.ts @@ -3,7 +3,7 @@ import { PassportStrategy } from '@nestjs/passport'; import { ExtractJwt, Strategy } from 'passport-jwt'; import { TokenPayload } from './user.dto'; import { OrganizationService } from '../organization/organization.service'; -import {Organization} from 'src/organization/organization.model'; +import { Organization } from 'src/organization/organization.model'; interface JwtStrategyValidate extends TokenPayload { organization: Organization; diff --git a/packages/server/src/organization/organization.context.ts b/packages/server/src/organization/organization.context.ts index e77bbf30..954009d9 100644 --- a/packages/server/src/organization/organization.context.ts +++ b/packages/server/src/organization/organization.context.ts @@ -1,5 +1,5 @@ import { createParamDecorator, ExecutionContext } from '@nestjs/common'; -import {GqlExecutionContext} from '@nestjs/graphql'; +import { GqlExecutionContext } from '@nestjs/graphql'; export const OrganizationContext = createParamDecorator((_data: unknown, ctx: ExecutionContext) => { const gqlCtx = GqlExecutionContext.create(ctx);