Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 15 additions & 17 deletions packages/client/src/graphql/graphql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -184,7 +180,6 @@ export type Mutation = {
completeTag: Scalars['Boolean']['output'];
completeUploadSession: UploadResult;
createDataset: Dataset;
createEntry: Entry;
createInvite: InviteModel;
createOrganization: Organization;
createProject: ProjectModel;
Expand All @@ -194,14 +189,14 @@ 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'];
lexiconCreate: Lexicon;
loginEmail: AccessToken;
loginGoogle: AccessToken;
loginUsername: AccessToken;
processEntryUploads: Scalars['Boolean']['output'];
refresh: AccessToken;
resendInvite: InviteModel;
resetPassword: Scalars['Boolean']['output'];
Expand All @@ -211,7 +206,6 @@ export type Mutation = {
updateProjectAuthMethods: ProjectModel;
updateProjectSettings: ProjectModel;
updateUser: UserModel;
uploadEntryCSV: Scalars['Boolean']['output'];
};


Expand Down Expand Up @@ -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<Scalars['Int']['input']>;
Expand Down Expand Up @@ -323,6 +311,11 @@ export type MutationForgotPasswordArgs = {
};


export type MutationGrantOwnerArgs = {
targetUser: Scalars['ID']['input'];
};


export type MutationLexiconAddEntryArgs = {
entry: LexiconAddEntry;
};
Expand Down Expand Up @@ -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 = {
Expand Down
7 changes: 7 additions & 0 deletions packages/client/src/graphql/organization/organization.graphql
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
query getOrganizations {
getOrganizations {
_id,
name,
authURL
}
}
49 changes: 49 additions & 0 deletions packages/client/src/graphql/organization/organization.ts
Original file line number Diff line number Diff line change
@@ -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<GetOrganizationsQuery, GetOrganizationsQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useQuery<GetOrganizationsQuery, GetOrganizationsQueryVariables>(GetOrganizationsDocument, options);
}
export function useGetOrganizationsLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<GetOrganizationsQuery, GetOrganizationsQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useLazyQuery<GetOrganizationsQuery, GetOrganizationsQueryVariables>(GetOrganizationsDocument, options);
}
export type GetOrganizationsQueryHookResult = ReturnType<typeof useGetOrganizationsQuery>;
export type GetOrganizationsLazyQueryHookResult = ReturnType<typeof useGetOrganizationsLazyQuery>;
export type GetOrganizationsQueryResult = Apollo.QueryResult<GetOrganizationsQuery, GetOrganizationsQueryVariables>;
79 changes: 58 additions & 21 deletions packages/client/src/pages/LoginPage.tsx
Original file line number Diff line number Diff line change
@@ -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<Organization | null>(null);
const [authURL, setAuthURL] = useState<string | null>(null);

// Fetch organizations
const getOrganizationsResults = useGetOrganizationsQuery();
const [organizations, setOrganizations] = useState<Organization[]>([]);
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 (
<Container component="main" maxWidth="xs">
<Box
Expand All @@ -32,17 +62,24 @@ export const LoginPage: FC = () => {
alignItems: 'center'
}}
>
<Avatar sx={{ m: 1, bgcolor: 'secondary.main' }}>
<LockOutlinedIcon />
</Avatar>
<Typography component="h1" variant="h5">
Sign In or Sign Up
</Typography>
<Typography>
<Link sx={{ fontStyle: 'italic', color: 'skyblue' }} href={authUrl}>
by following this link
</Link>
</Typography>
<Typography variant="h2">Login</Typography>
<FormControl sx={{ m: 1 }}>
<Select
sx={{ width: 300, m: 1 }}
label="Organization"
onChange={handleOrganizationChange}
value={organization ? organization._id : ''}
>
{organizations.map((organization) => (
<MenuItem key={organization._id} value={organization._id}>
{organization.name}
</MenuItem>
))}
</Select>
<Button disabled={organization == null} variant="contained" onClick={loginRedirect}>
{organization ? 'Redirect to Organization Login' : 'Select an Organization to Login'}
</Button>
</FormControl>
</Box>
</Container>
);
Expand Down
7 changes: 7 additions & 0 deletions packages/server/schema.gql
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@
type Organization {
_id: ID!
name: String!

"""URL where the user logs in against"""
authURL: String!
}

type Dataset {
Expand Down Expand Up @@ -138,6 +141,10 @@ type Mutation {

input OrganizationCreate {
name: String!

"""URL where the user logs in against"""
authURL: String!
projectId: String!
}

input DatasetCreate {
Expand Down
10 changes: 6 additions & 4 deletions packages/server/src/auth/auth.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand All @@ -26,7 +27,8 @@ import { AuthResolver } from './auth.resolver';
};
return options;
}
})
}),
forwardRef(() => OrganizationModule)
],
providers: [
AuthService,
Expand All @@ -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);
}
}
],
Expand Down
27 changes: 23 additions & 4 deletions packages/server/src/auth/jwt.strategy.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,38 @@
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,
secretOrKey: publicKey
});
}

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<JwtStrategyValidate> {
const organization = await this.organizationService.findByProject(payload.projectId);
if (!organization) {
throw new BadGatewayException('Organization not found');
}

return {
...payload,
organization: organization
};
}
}
11 changes: 9 additions & 2 deletions packages/server/src/organization/dtos/create.dto.ts
Original file line number Diff line number Diff line change
@@ -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;
}
7 changes: 4 additions & 3 deletions packages/server/src/organization/organization.context.ts
Original file line number Diff line number Diff line change
@@ -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;
});
8 changes: 8 additions & 0 deletions packages/server/src/organization/organization.model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,14 @@ export class Organization {
@Prop()
@Field()
name: string;

/** 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;
Expand Down
Loading