Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implements all orgs page #548

Merged
merged 23 commits into from
Oct 18, 2024
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
c901109
pull data for all orgs page
echappen Oct 3, 2024
97f6158
add last visited org link to all orgs page
echappen Oct 4, 2024
5fc3a57
improve error handling for all orgs page
echappen Oct 4, 2024
6224148
Fix: include all org routes in middleware matches; set org id cookie …
echappen Oct 4, 2024
f6253c6
apply UI design to all orgs page
echappen Oct 7, 2024
4640fb5
apply initial mobile styles to all orgs cards
echappen Oct 7, 2024
0a9629e
pull org roles for current user for all orgs page
echappen Oct 9, 2024
ee8187c
use package for converting MB to more human-friendly values
echappen Oct 9, 2024
794b096
refine tablet-ish breakpoints
echappen Oct 9, 2024
ebbdcf2
add real hrefs for "at a glance" links
echappen Oct 9, 2024
4c696f6
refactor org components
echappen Oct 9, 2024
930d917
add some spacing between roles and at a glance sections
echappen Oct 10, 2024
6fe6dec
handle indefinite articles for role names
echappen Oct 10, 2024
44df6cc
remove hyperlink for spaces
echappen Oct 10, 2024
3429878
add timestamp to all orgs page
echappen Oct 10, 2024
9afa2ad
Fix: remove key prop error message
echappen Oct 10, 2024
733ebd0
do not cache usage summary requests
echappen Oct 10, 2024
000fec6
move timestamp to top of org list
echappen Oct 10, 2024
7567bbf
add design for progress bar for when there's no memory limit
echappen Oct 15, 2024
c23d77b
add instructions for how to obtain your own CF user ID
echappen Oct 17, 2024
4661990
modify memory used/allocated language in memory bar
echappen Oct 17, 2024
a0333d0
make timestamp component more reusable
echappen Oct 17, 2024
1ce56ec
remove bottom margin from At a Glance section of org card
echappen Oct 17, 2024
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
5 changes: 4 additions & 1 deletion .env.example.local
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,13 @@ UAA_LOGOUT_PATH=/logout.do
# CF API
# Used to connect to the Cloud Foundry API. CF_API_URL should always end
# with /v3 regardless of the environment.
# The CF_API_TOKEN can be populated with `cf oauth-token` or by running
# Locally, use CF_USER_ID to get CAPI info related to the current logged in user.
# In a deployed environment, this user id comes from the auth token.
# Locally, the CF_API_TOKEN can be populated with `cf oauth-token` or by running
# npm run dev-cf
#
CF_API_URL=https://api.dev.us-gov-west-1.aws-us-gov.cloud.gov/v3
CF_USER_ID=your-cf-user-guid
echappen marked this conversation as resolved.
Show resolved Hide resolved
CF_API_TOKEN=

# S3
Expand Down
1 change: 1 addition & 0 deletions .env.test
Original file line number Diff line number Diff line change
Expand Up @@ -11,5 +11,6 @@ UAA_LOGOUT_PATH=/logout.do

CF_API_URL=https://example.com
CF_API_TOKEN=placeholder
CF_USER_ID=placeholder

NEXT_PUBLIC_USER_INVITE_URL=https://account.dev.us-gov-west-1.aws-us-gov.cloud.gov/invite
51 changes: 50 additions & 1 deletion __tests__/api/cf/token.test.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { cookies } from 'next/headers';
import { describe, it, expect, beforeEach, afterEach } from '@jest/globals';
import { getToken, isLoggedIn } from '@/api/cf/token';
import { getToken, isLoggedIn, getUserId } from '@/api/cf/token';

/* global jest */
/* eslint no-undef: "off" */
Expand Down Expand Up @@ -64,3 +64,52 @@ describe('cloudfoundry token tests', () => {
});
});
});

describe('cloudfoundry user id tests', () => {
describe('when CF_USER_ID environment variable is set', () => {
beforeEach(() => {
process.env.CF_USER_ID = 'foo-user-id';
});
afterEach(() => {
delete process.env.CF_USER_ID;
});
it('getUserId() returns a manual token', () => {
expect(getUserId()).toBe('foo-user-id');
});
});

describe('when CF_USER_ID environment variable is not set', () => {
describe('when auth cookie is set', () => {
beforeEach(() => {
cookies.mockImplementation(() => ({
get: () => ({ value: '{"user_id":"foo-user-id"}' }),
}));
});
it('getUserId() returns a token from a cookie', () => {
expect(getUserId()).toBe('foo-user-id');
});
});
describe('when auth cookie is not set', () => {
beforeEach(() => {
cookies.mockImplementation(() => ({
get: () => undefined,
}));
});
it('getToken() throws an error when no cookie is set', () => {
expect(() => getUserId()).toThrow('please confirm you are logged in');
});
});
describe('when auth cookie is not in an expected format', () => {
beforeEach(() => {
cookies.mockImplementation(() => ({
get: () => 'unexpected format',
}));
});
it('getToken() throws an error', () => {
expect(() => getUserId()).toThrow(
'unable to parse authsession user_id'
);
});
});
});
});
33 changes: 32 additions & 1 deletion __tests__/controllers/controller-helpers.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,29 @@ import {
memoryUsagePerOrg,
countSpacesPerOrg,
countAppsPerOrg,
getOrgRolesForCurrentUser,
} from '@/controllers/controller-helpers';
import { mockUsersByOrganization, mockUsersBySpace } from '../api/mocks/roles';
import {
mockUsersByOrganization,
mockUsersBySpace,
mockRolesFilteredByOrgAndUser,
} from '../api/mocks/roles';
import { mockS3Object } from '../api/mocks/lastlogon-summary';
import nock from 'nock';
import mockUsers from '../api/mocks/users';
import { mockOrgQuotas } from '../api/mocks/orgQuotas';
import { mockSpaces } from '../api/mocks/spaces';
import { mockApps } from '../api/mocks/apps';
// eslint-disable-next-line no-unused-vars
import { getUserId } from '@/api/cf/token';

/* global jest */
/* eslint no-undef: "off" */
jest.mock('@/api/cf/token', () => ({
...jest.requireActual('../../src/api/cf/token'),
getUserId: jest.fn(() => '46ff1fd5-4238-4e22-a00a-1bec4fc0f9da'), // same user guid as in mockRolesFilteredByOrgAndUser
}));
/* eslint no-undef: "error" */

beforeEach(() => {
if (!nock.isActive()) {
Expand Down Expand Up @@ -307,4 +322,20 @@ describe('controller-helpers', () => {
expect(result['orgId2']).toEqual(2);
});
});

describe('getOrgRolesForCurrentUser', () => {
it('returns an object keyed by org id with value of array of role types', async () => {
// setup
const orgGuids = ['e8e31994-0dba-41e3-96ea-39942f1b30a4'];
nock(process.env.CF_API_URL)
.get(/roles/)
.reply(200, mockRolesFilteredByOrgAndUser);
// act
const result = await getOrgRolesForCurrentUser(orgGuids);
// assert
expect(result['e8e31994-0dba-41e3-96ea-39942f1b30a4']).toEqual([
'organization_manager',
]);
});
});
});
9 changes: 9 additions & 0 deletions __tests__/controllers/controllers.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ jest.mock('@/controllers/controller-helpers', () => ({
memoryUsagePerOrg: () => ({ orgId1: 4, orgId2: 5 }),
countSpacesPerOrg: () => ({ orgId1: 6, orgId2: 7 }),
countAppsPerOrg: () => ({ orgId1: 8, orgId2: 9 }),
getOrgRolesForCurrentUser: () => ({
orgId1: ['organization_manager'],
orgId2: ['organization_billing_manager'],
}),
}));
jest.mock('@/api/aws/s3', () => ({
getUserLogonInfo: jest.fn(),
Expand Down Expand Up @@ -568,6 +572,11 @@ describe('controllers tests', () => {
// apps
expect(result.payload.appCounts['orgId1']).toEqual(8);
expect(result.payload.appCounts['orgId2']).toEqual(9);
// roles
expect(result.payload.roles['orgId1']).toEqual(['organization_manager']);
expect(result.payload.roles['orgId2']).toEqual([
'organization_billing_manager',
]);
});
});
});
13 changes: 12 additions & 1 deletion __tests__/middleware.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,16 @@ import { middleware } from '@/middleware.ts';
import { postToAuthTokenUrl } from '@/api/auth';

const mockEmailAddress = '[email protected]';
const mockAccessToken = jwt.sign({ email: mockEmailAddress }, 'fooPrivateKey');
const mockUserName = 'fooUserName';
const mockUserId = 'fooUserId';
const mockAccessToken = jwt.sign(
{
email: mockEmailAddress,
user_name: mockUserName,
user_id: mockUserId,
},
'fooPrivateKey'
);
const mockRefreshToken = 'fooRefreshToken';
const mockExpiry = 43199;
const mockAuthResponse = {
Expand Down Expand Up @@ -84,6 +93,8 @@ describe('auth/login/callback', () => {
response.cookies.get('authsession')['value']
);
expect(authCookieObj.email).toMatch(mockEmailAddress);
expect(authCookieObj.user_id).toMatch(mockUserId);
expect(authCookieObj.user_name).toMatch(mockUserName);
expect(authCookieObj.accessToken).toMatch(mockAccessToken);
expect(authCookieObj.refreshToken).toMatch(mockRefreshToken);
expect(authCookieObj.expiry).toBeDefined();
Expand Down
6 changes: 5 additions & 1 deletion src/api/cf/cloudfoundry-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { redirect } from 'next/navigation';
import { logInPath } from '@/helpers/authentication';
import { camelToSnakeCase } from '@/helpers/text';
import { request } from '../api';
import { getToken } from './token';
import { getToken, getUserId } from './token';

type MethodType = 'delete' | 'get' | 'patch' | 'post';

Expand Down Expand Up @@ -75,3 +75,7 @@ export async function prepPathParams(options: {
const urlParams = new URLSearchParams(params);
return `?${urlParams.toString()}`;
}

export async function getCurrentUserId() {
return getUserId();
}
19 changes: 19 additions & 0 deletions src/api/cf/token.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,22 @@ export function isLoggedIn(): boolean {
return false;
}
}

export function getUserId() {
return getLocalUserId() || getCFUserId();
}

export function getLocalUserId() {
return process.env.CF_USER_ID;
}

export function getCFUserId() {
const authSession = cookies().get('authsession');
if (authSession === undefined)
throw new Error('please confirm you are logged in');
try {
return JSON.parse(authSession.value).user_id;
} catch (error: any) {
throw new Error('unable to parse authsession user_id');
}
}
1 change: 1 addition & 0 deletions src/app/orgs/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export default async function OrgsPage() {
memoryAllocated={payload.memoryAllocated}
memoryCurrentUsage={payload.memoryCurrentUsage}
spaceCounts={payload.spaceCounts}
roles={payload.roles}
/>
</div>
);
Expand Down
28 changes: 25 additions & 3 deletions src/components/OrganizationsList/OrganizationsList.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import React from 'react';
import { OrgObj } from '@/api/cf/cloudfoundry-types';
import { chunkArray, sortObjectsByParam } from '@/helpers/arrays';
import Link from 'next/link';
import { MemoryBar } from '@/components/MemoryBar';
import { formatInt } from '@/helpers/numbers';
import { formatOrgRoleName } from '@/helpers/text';

export function OrganizationsList({
orgs,
Expand All @@ -11,13 +13,15 @@ export function OrganizationsList({
memoryAllocated,
memoryCurrentUsage,
spaceCounts,
roles,
}: {
orgs: Array<OrgObj>;
userCounts: { [orgGuid: string]: number };
appCounts: { [orgGuid: string]: number };
memoryAllocated: { [orgGuid: string]: number };
memoryCurrentUsage: { [orgGuid: string]: number };
spaceCounts: { [orgGuid: string]: number };
roles: { [orgGuid: string]: Array<string> };
}) {
if (!orgs.length) {
return <>no orgs found</>;
Expand All @@ -26,6 +30,25 @@ export function OrganizationsList({
const orgsSorted = sortObjectsByParam(orgs, 'name');
const orgsGrouped = chunkArray(orgsSorted, 3);
echappen marked this conversation as resolved.
Show resolved Hide resolved

const getOrgRolesText = (orgGuid: string): React.ReactNode => {
const orgRoles = roles[orgGuid];
if (!orgRoles || !orgRoles.length) {
return (
<>
You're a <strong>User</strong> in this organization.
</>
);
}
const formattedRoles = orgRoles
.map<React.ReactNode>((r) => (
<span className="text-bold text-capitalize" key={`${orgGuid}-${r}`}>
{formatOrgRoleName(r).replace('org ', '')}
</span>
))
.reduce((prev, cur) => [prev, ' and ', cur]);
return <>You're a {formattedRoles} in this organization.</>;
echappen marked this conversation as resolved.
Show resolved Hide resolved
};

return (
<div className="margin-y-4">
{orgsGrouped.map((orgGoup, groupIndex) => {
Expand All @@ -51,9 +74,8 @@ export function OrganizationsList({
</h2>

<div className="display-flex flex-justify">
<div className="maxw-15 font-sans-3xs line-height-sans-4">
TODO: You’re a <strong>Manager</strong> and a{' '}
<strong>Billing Manager</strong> in this organization.
<div className="maxw-card font-sans-3xs line-height-sans-4">
{getOrgRolesText(org.guid)}
</div>
<div className="maxw-15 font-sans-3xs line-height-sans-4">
<p className="margin-top-0 margin-bottom-1 text-uppercase">
Expand Down
29 changes: 28 additions & 1 deletion src/controllers/controller-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@ import * as CF from '@/api/cf/cloudfoundry';
import { OrgQuotaObject, RolesByUser, SpaceRoleMap } from './controller-types';
import { RoleObj, UserObj, SpaceObj } from '@/api/cf/cloudfoundry-types';
import { UserLogonInfoById } from '@/api/aws/s3-types';
import { cfRequestOptions } from '@/api/cf/cloudfoundry-helpers';
import {
cfRequestOptions,
getCurrentUserId,
} from '@/api/cf/cloudfoundry-helpers';
import { request } from '@/api/api';
import { delay } from '@/helpers/timeout';

Expand Down Expand Up @@ -264,3 +267,27 @@ export async function countAppsPerOrg(
return acc;
}, {});
}

export async function getOrgRolesForCurrentUser(orgGuids: Array<string>) {
let roles: { [orgId: string]: Array<string> } = {};
const userId = await getCurrentUserId();
if (userId) {
const rolesRes = await CF.getRoles({
userGuids: [userId],
organizationGuids: orgGuids,
});
const rolesJson = (await rolesRes.json()).resources;
roles = rolesJson.reduce(
(acc: { [orgId: string]: Array<string> }, curRole: RoleObj) => {
if (curRole.type === 'organization_user') return acc;
const orgId = curRole.relationships.organization.data.guid;
if (!orgId) return acc;
if (!acc[orgId]) acc[orgId] = [];
acc[orgId].push(curRole.type);
return acc;
},
{}
);
}
return roles;
}
4 changes: 2 additions & 2 deletions src/controllers/controllers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
memoryUsagePerOrg,
countSpacesPerOrg,
countAppsPerOrg,
getOrgRolesForCurrentUser,
} from './controller-helpers';
import { sortObjectsByParam } from '@/helpers/arrays';
import { daysToExpiration } from '@/helpers/dates';
Expand Down Expand Up @@ -90,8 +91,7 @@ export async function getOrgsPage(): Promise<ControllerResult> {
const memoryCurrentUsage = await memoryUsagePerOrg(orgGuids);
const spaceCounts = await countSpacesPerOrg(orgGuids);
const appCounts = await countAppsPerOrg(orgGuids);
// get user's roles for each org
const roles = {};
const roles = await getOrgRolesForCurrentUser(orgGuids);

return {
payload: {
Expand Down
2 changes: 2 additions & 0 deletions src/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@ export function setAuthCookie(
'authsession',
JSON.stringify({
accessToken: data.access_token,
user_id: decodedToken.user_id,
user_name: decodedToken.user_name,
email: decodedToken.email,
refreshToken: data.refresh_token,
expiry: Date.now() + data.expires_in * 1000,
Expand Down