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
2 changes: 1 addition & 1 deletion website/src/components/Group/GroupForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ export const GroupForm: FC<GroupFormProps> = ({ title, buttonText, defaultGroupD

<div className='mt-5 grid grid-cols-1 gap-x-6 gap-y-4 sm:grid-cols-6'>
<GroupNameInput defaultValue={defaultGroupData?.groupName} />
<EmailContactInput defaultValue={defaultGroupData?.contactEmail} />
<EmailContactInput defaultValue={defaultGroupData?.contactEmail ?? ''} />
<InstitutionNameInput defaultValue={defaultGroupData?.institution} />
<AddressLineOneInput defaultValue={defaultGroupData?.address.line1} />
<AddressLineTwoInput defaultValue={defaultGroupData?.address.line2} />
Expand Down
75 changes: 75 additions & 0 deletions website/src/components/User/GroupPage.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { render, screen } from '@testing-library/react';
import { describe, expect, test } from 'vitest';

import { GroupPage } from './GroupPage';
import { testAccessToken, testConfig, testDatabaseName, testGroups, testUser } from '../../../vitest.setup';

const DUMMY_GROUP_DETAILS = {
group: testGroups[0],
users: [testUser],
};

describe('GroupPage', () => {
test('test authenticated can edit view', () => {
render(
<GroupPage
prefetchedGroupDetails={DUMMY_GROUP_DETAILS}
clientConfig={testConfig.public}
accessToken={testAccessToken}
username={testUser.name}
userGroups={testGroups}
organisms={[]}
databaseName={testDatabaseName}
loginUrl=''
/>,
);

const targetString = `to ${testDatabaseName} see full group details`;
expect(screen.queryByText(targetString)).toBeNull();
expect(screen.getByText(testGroups[0].contactEmail!)).toBeVisible();
expect(screen.getByRole('heading', { name: `Sequences available in ${testDatabaseName}` })).toBeVisible();
expect(screen.getByRole('heading', { name: 'Users' })).toBeVisible();
});

test('test authenticated cannot edit view', () => {
render(
<GroupPage
prefetchedGroupDetails={DUMMY_GROUP_DETAILS}
clientConfig={testConfig.public}
accessToken={testAccessToken}
username={testUser.name}
userGroups={[]}
organisms={[]}
databaseName={testDatabaseName}
loginUrl=''
/>,
);

const targetString = `to ${testDatabaseName} see full group details`;
expect(screen.queryByText(targetString)).toBeNull();
expect(screen.getByText(testGroups[0].contactEmail!)).toBeVisible();
expect(screen.getByRole('heading', { name: `Sequences available in ${testDatabaseName}` })).toBeVisible();
expect(screen.queryByRole('heading', { name: /users/i })).toBeNull();
});

test('test unauthenticated view', () => {
render(
<GroupPage
prefetchedGroupDetails={DUMMY_GROUP_DETAILS}
clientConfig={testConfig.public}
accessToken=''
username=''
userGroups={[]}
organisms={[]}
databaseName={testDatabaseName}
loginUrl=''
/>,
);

const targetString = `to ${testDatabaseName} to see contact details for the group.`;
expect(screen.queryByText(targetString)).toBeVisible();
expect(screen.queryByText(testGroups[0].contactEmail!)).toBeNull();
expect(screen.getByRole('heading', { name: `Sequences available in ${testDatabaseName}` })).toBeVisible();
expect(screen.queryByRole('heading', { name: /users/i })).toBeNull();
});
});
29 changes: 21 additions & 8 deletions website/src/components/User/GroupPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,13 @@ import IwwaArrowDown from '~icons/iwwa/arrow-down';
type GroupPageProps = {
prefetchedGroupDetails: GroupDetails;
clientConfig: ClientConfig;
accessToken: string;
accessToken: string | undefined;
username: string;
userGroups: Group[];
organisms: Organism[];
databaseName: string;
continueSubmissionIntent?: ContinueSubmissionIntent;
loginUrl: string;
};

const InnerGroupPage: FC<GroupPageProps> = ({
Expand All @@ -38,6 +39,7 @@ const InnerGroupPage: FC<GroupPageProps> = ({
organisms,
databaseName,
continueSubmissionIntent,
loginUrl,
}) => {
const groupName = prefetchedGroupDetails.group.groupName;
const groupId = prefetchedGroupDetails.group.groupId;
Expand All @@ -58,7 +60,7 @@ const InnerGroupPage: FC<GroupPageProps> = ({
setNewUserName('');
};

const userIsGroupMember = groupDetails.data?.users.some((user) => user.name === username) ?? false;
const userIsGroupMember = groupDetails.data?.users?.some((user) => user.name === username) ?? false;
const userHasEditPrivileges = userGroups.some((group) => group.groupId === prefetchedGroupDetails.group.groupId);

const { data: sequenceCounts, isLoading: sequenceCountsLoading } = useQuery({
Expand Down Expand Up @@ -152,7 +154,7 @@ const InnerGroupPage: FC<GroupPageProps> = ({
<Button
className='object-right p-2 loculusColor text-white rounded px-4'
onClick={() => {
const isLastMember = (groupDetails.data?.users.length ?? 0) <= 1;
const isLastMember = (groupDetails.data?.users?.length ?? 0) <= 1;
const lastMemberWarning =
'You are the last user in this group. Leaving will leave the group without any members, meaning that nobody is able to add future members. ';
const dialogText = `${isLastMember ? lastMemberWarning : ''}Are you sure you want to leave the ${groupName} group?`;
Expand All @@ -172,9 +174,8 @@ const InnerGroupPage: FC<GroupPageProps> = ({
)}
</div>
) : (
<h1 className='flex flex-row gap-4 title flex-grow'>
<label className='block title'>Group:</label>
{groupName}
<h1 className='flex flex-col title flex-grow'>
<label className='block title'>Group: {groupName}</label>
</h1>
)}

Expand All @@ -183,12 +184,24 @@ const InnerGroupPage: FC<GroupPageProps> = ({
<tbody>
<TableRow label='Group ID'>{groupDetails.data?.group.groupId}</TableRow>
<TableRow label='Institution'>{groupDetails.data?.group.institution}</TableRow>
<TableRow label='Contact email'>{groupDetails.data?.group.contactEmail}</TableRow>
{accessToken && (
<TableRow label='Contact email'>{groupDetails.data?.group.contactEmail}</TableRow>
)}
<TableRow label='Address'>
<PostalAddress address={groupDetails.data?.group.address} />
</TableRow>
</tbody>
</table>
<div className='w-full mt-2 text-center'>
{!accessToken && (
<span className='text-sm italic'>
<a href={loginUrl} className='underline cursor-pointer'>
Log in
</a>{' '}
to {databaseName} to see contact details for the group.
</span>
)}
</div>
</div>

<div className=' max-w-2xl mx-auto px-10 py-4 bg-gray-100 rounded-md my-4'>
Expand Down Expand Up @@ -233,7 +246,7 @@ const InnerGroupPage: FC<GroupPageProps> = ({
</form>
<div className='flex-1 overflow-y-auto'>
<ul>
{groupDetails.data?.users.map((user) => (
{groupDetails.data?.users?.map((user) => (
<li key={user.name} className='flex items-center gap-6 bg-gray-100 p-2 mb-2 rounded'>
<span className='text-lg'>{user.name}</span>
{user.name !== username && (
Expand Down
6 changes: 3 additions & 3 deletions website/src/hooks/useGroupOperations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { stringifyMaybeAxiosError } from '../utils/stringifyMaybeAxiosError.ts';

type UseGroupOperationsProps = {
clientConfig: ClientConfig;
accessToken: string;
accessToken: string | undefined;
setErrorMessage: (message?: string) => void;
};

Expand Down Expand Up @@ -162,7 +162,7 @@ function callEditGroup(accessToken: string, zodios: ZodiosInstance<typeof groupM
}

function callRemoveFromGroup(
accessToken: string,
accessToken: string | undefined,
openErrorFeedback: (message: string | undefined) => void,
zodios: ZodiosInstance<typeof groupManagementApi>,
refetchGroups: () => Promise<unknown>,
Expand All @@ -185,7 +185,7 @@ function callRemoveFromGroup(
}

function callAddToGroup(
accessToken: string,
accessToken: string | undefined,
openErrorFeedback: (message: string | undefined) => void,
zodios: ZodiosInstance<typeof groupManagementApi>,
refetchGroups: () => Promise<unknown>,
Expand Down
2 changes: 1 addition & 1 deletion website/src/pages/group/[groupId]/edit.astro
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ if (isNaN(groupId)) {
}

const groupManagementClient = GroupManagementClient.create();
const groupDetailsResult = await groupManagementClient.getGroupDetails(accessToken, groupId);
const groupDetailsResult = await groupManagementClient.getGroupDetails(groupId, accessToken);
---

<BaseLayout title='Edit group' activeTopNavigationItem='account'>
Expand Down
46 changes: 22 additions & 24 deletions website/src/pages/group/[groupId]/index.astro
Original file line number Diff line number Diff line change
@@ -1,20 +1,21 @@
---
import { GroupPage } from '../../../components/User/GroupPage';
import ErrorBox from '../../../components/common/ErrorBox.tsx';
import NeedToLogin from '../../../components/common/NeedToLogin.astro';
import { getConfiguredOrganisms, getRuntimeConfig, getWebsiteConfig } from '../../../config';
import BaseLayout from '../../../layouts/BaseLayout.astro';
import type { ContinueSubmissionIntent } from '../../../routes/routes';
import { GroupManagementClient } from '../../../services/groupManagementClient';
import { getAccessToken } from '../../../utils/getAccessToken';
import { getAuthUrl } from '../../../utils/getAuthUrl';

const session = Astro.locals.session!;
const accessToken = getAccessToken(session)!;
const username = session.user?.username ?? '';
const session = Astro.locals.session;
const accessToken = getAccessToken(session);
const username = session?.user?.username ?? '';
const groupId = parseInt(Astro.params.groupId!, 10);
const clientConfig = getRuntimeConfig().public;
const organisms = getConfiguredOrganisms();
const databaseName = getWebsiteConfig().name;
const loginUrl = await getAuthUrl(Astro.url.toString());

const continueSubmissionOrganism = Astro.url.searchParams.get('continueSubmissionOrganism');

Expand All @@ -28,7 +29,7 @@ if (isNaN(groupId)) {
}

const groupManagementClient = GroupManagementClient.create();
const groupDetailsResult = await groupManagementClient.getGroupDetails(accessToken, groupId);
const groupDetailsResult = await groupManagementClient.getGroupDetails(groupId, accessToken);
const userGroupsResponse = await groupManagementClient.getGroupsOfUser(accessToken);
const userGroups = userGroupsResponse.match(
(groups) => groups,
Expand All @@ -44,25 +45,22 @@ const userGroups = userGroupsResponse.match(
activeTopNavigationItem='account'
>
{
!accessToken ? (
<NeedToLogin message='You need to be logged in to view group information.' />
) : (
groupDetailsResult.match(
(groupDetails) => (
<GroupPage
prefetchedGroupDetails={groupDetails}
accessToken={accessToken}
clientConfig={clientConfig}
username={username}
userGroups={userGroups}
organisms={organisms}
databaseName={databaseName}
continueSubmissionIntent={continueSubmissionIntent}
client:load
/>
),
() => <ErrorBox>Failed to fetch group details, sorry for the inconvenience!</ErrorBox>,
)
groupDetailsResult.match(
(groupDetails) => (
<GroupPage
prefetchedGroupDetails={groupDetails}
accessToken={accessToken}
clientConfig={clientConfig}
username={username}
userGroups={userGroups}
organisms={organisms}
databaseName={databaseName}
continueSubmissionIntent={continueSubmissionIntent}
loginUrl={loginUrl}
client:load
/>
),
() => <ErrorBox>Failed to fetch group details, sorry for the inconvenience!</ErrorBox>,
)
}
</BaseLayout>
8 changes: 8 additions & 0 deletions website/src/services/commonApiTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,14 @@ export const [authorizationHeader] = makeParameters([
},
]);

export const [optionalAuthorizationHeader] = makeParameters([
{
name: 'Authorization',
type: 'Header',
schema: z.string().includes('Bearer ', { position: 0 }).optional(),
},
]);

export function withOrganismPathSegment<Path extends `/${string}`>(path: Path) {
return `/:organism${path}` as const;
}
Expand Down
10 changes: 7 additions & 3 deletions website/src/services/groupManagementApi.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import { makeApi, makeEndpoint } from '@zodios/core';
import z from 'zod';

import { authorizationHeader, conflictError, notAuthorizedError } from './commonApiTypes.ts';
import {
authorizationHeader,
conflictError,
notAuthorizedError,
optionalAuthorizationHeader,
} from './commonApiTypes.ts';
import { group, groupDetails, newGroup } from '../types/backend.ts';
const createGroupEndpoint = makeEndpoint({
method: 'post',
Expand Down Expand Up @@ -53,9 +58,8 @@ const getGroupDetailsEndpoint = makeEndpoint({
method: 'get',
path: '/groups/:groupId',
alias: 'getGroupDetails',
parameters: [authorizationHeader],
parameters: [optionalAuthorizationHeader],
response: groupDetails,
errors: [notAuthorizedError],
});
const getGroupsOfUserEndpoint = makeEndpoint({
method: 'get',
Expand Down
4 changes: 2 additions & 2 deletions website/src/services/groupManagementClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,13 @@ export class GroupManagementClient extends ZodiosWrapperClient<typeof groupManag
);
}

public getGroupsOfUser(token: string) {
public getGroupsOfUser(token: string | undefined) {
return this.call('getGroupsOfUser', {
headers: createAuthorizationHeader(token),
});
}

public getGroupDetails(token: string, groupId: number) {
public getGroupDetails(groupId: number, token?: string) {
return this.call('getGroupDetails', {
headers: createAuthorizationHeader(token),
params: { groupId },
Expand Down
18 changes: 12 additions & 6 deletions website/src/types/backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -296,7 +296,7 @@ export const newGroup = z.object({
groupName: z.string(),
institution: z.string(),
address,
contactEmail: z.string(),
contactEmail: z.string().nullable(),
});
export type NewGroup = z.infer<typeof newGroup>;

Expand All @@ -307,11 +307,17 @@ export type Group = z.infer<typeof group>;

export const groupDetails = z.object({
group,
users: z.array(
z.object({
name: z.string(),
}),
),
/**
* List of users in the group.
* Null when the requesting user is not authenticated or not authorized to view members.
*/
users: z
.array(
z.object({
name: z.string(),
}),
)
.nullable(),
});

export type GroupDetails = z.infer<typeof groupDetails>;
Expand Down
4 changes: 2 additions & 2 deletions website/src/utils/createAuthorizationHeader.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
export function createAuthorizationHeader(token: string) {
return { Authorization: `Bearer ${token}` }; // eslint-disable-line @typescript-eslint/naming-convention
export function createAuthorizationHeader(token?: string) {
return token ? { Authorization: `Bearer ${token}` } : undefined; // eslint-disable-line @typescript-eslint/naming-convention
}
4 changes: 4 additions & 0 deletions website/vitest.setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ export const DEFAULT_GROUP_NAME = 'testGroup';

export const testOrganism = 'testOrganism';

export const testDatabaseName = 'testDatabase';

export const testConfig = {
public: {
discriminator: 'client',
Expand Down Expand Up @@ -313,6 +315,8 @@ const lapisRequestMocks = {
},
};

export const testUser = { name: 'testUser' };

export const testGroups: Group[] = [
{
groupId: 1,
Expand Down
Loading