diff --git a/.env.example.local b/.env.example.local index da9cb259..a044a106 100644 --- a/.env.example.local +++ b/.env.example.local @@ -43,3 +43,6 @@ CF_API_TOKEN= # https://nextjs.org/docs/app/building-your-application/configuring/environment-variables#bundling-environment-variables-for-the-browser NEXT_PUBLIC_USER_INVITE_URL=https://account.dev.us-gov-west-1.aws-us-gov.cloud.gov/invite NEXT_PUBLIC_CLOUD_SUPPORT_URL="mailto:support@cloud.gov?body=What+is+your+question%3F%0D%0A%0D%0APlease+provide+your+application+name+or+URL.+Do+not+include+any+sensitive+information+about+your+platform+in+this+email." + +# feed for cloud.gov blog posts +NEXT_PUBLIC_BLOG_FEED_URL=https://cloud.gov/updates.xml diff --git a/.env.test b/.env.test index 63692455..2b7c84e2 100644 --- a/.env.test +++ b/.env.test @@ -14,3 +14,4 @@ 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 +NEXT_PUBLIC_BLOG_FEED_URL=https://cloud.gov/updates.xml diff --git a/README.md b/README.md index 62810546..73c026e3 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,26 @@ cf login -a api.fr.cloud.gov --sso You do not need to target an organization or space. -### Step 3: Configure the application +### Step 3: Run a local user accounts and authentication (UAA) server + +Users will need to authenticate through UAA in order to view the application. A real UAA flow can't be done locally, because UAA can't whitelist `localhost`. So in local development, we simulate authentication in two ways: + +1) **By running a local UAA server** - This login flow provides fake `authsession` cookie data. The presence of this cookie is what allows you to visit authenticated pages when navigating the app. +2) **By setting CloudFoundry data in our environment file** - Because local UAA returns fake data, we need to obtain real CF credentials through the CF CLI and keep them in `.env.local`. This allows you to get real CloudFoundry API data. (Handled in steps 4 and 5.) + +See the [uaa-docker README](uaa-docker/README.md) for set up instructions. + +In another shell, start the UAA container: + +```bash +cd uaa-docker +# follow instructions to build before running up +docker compose up +``` + +Use credentials found in `uaa-docker/uaa.yml` to log in. + +### Step 4: Configure the application Copy the example `.env.example.local` file. Do not check `.env.local` into source control. @@ -64,16 +83,18 @@ Copy the example `.env.example.local` file. Do not check `.env.local` into sourc cp .env.example.local .env.local ``` -You do not need to change anything about this file for local development unless if you logged into your production cloud.gov account during the previous step. +You do not need to change anything about this file for local development unless you logged into your production cloud.gov account during the previous step. ```bash # change this line if you are using production CF_API_URL=https://api.fr.cloud.gov/v3 ``` +For certain pages, you'll also need to set your `CF_USER_ID`. This is normally returned from UAA and placed in the `authsession` cookie, but when working locally, you'll need to obtain this from your CloudFoundry environment (like by running `cf curl '/v3/users'` or by running `cf oauth-token` and decoding the returned JWT token). + Note: the variable `CF_API_TOKEN` is not yet populated. That's okay! Continue to the next step to set it. -### Step 4: Run the app! +### Step 5: Run the app! Start the app with the `dev-cf` command: @@ -91,7 +112,7 @@ Due to developing locally against a "real" environment, we have to play by the r If you start getting 401 errors, restart your application to get a new token. If you haven't logged into the CF CLI on a given day, make sure to reauthenticate following Step 2 above. -### Step 5: Optional stretch goals +### Step 6: Optional stretch goals The above steps are enough to get most people up and running, but our app has more bells and whistles! Consider yourself good to go unless if you plan to do any of the following: @@ -147,24 +168,8 @@ docker compose build docker compose up ``` -#### Local user accounts and authentication (UAA) - -Our local version of the app uses a CF token to access the CF API. However, the deployed version of the application authenticates with the CF User Accounts and Authentication (UAA) service. We have a local version of UAA available if you wish to test or develop around the user experience of logging in. - -See the [uaa-docker README](uaa-docker/README.md) for set up instructions. - -Start the container: - -```bash -cd uaa-docker -# follow instructions to build before running up -docker compose up -``` - -In order to try out UAA, you will need to comment out your CF_API_TOKEN and then use credentials found in the `uaa-docker/uaa.yml` file. - -### Step 6: Testing +### Step 7: Testing To run the entire unit test suite, you will need to start the docker database container: @@ -199,7 +204,7 @@ Then, if you have not already, set up a [s3 service key](#s3-user-information). npm run test:integration ``` -### Step 7: Committing +### Step 8: Committing #### Preparing your code @@ -235,7 +240,7 @@ Cloud.gov requires any commits to this repo to be signed with a GPG key. [You wi Cloud.gov requires that contributors have the [caulking tool](https://github.com/cloud-gov/caulking) installed and running on their machines. Follow the instructions to install caulking and confirm that `make audit` passes all checks. -## Step 8: Deploying +## Step 9: Deploying This application is deployed to the cloud.gov development environment automatically when changes are merged into the main branch. Deployment is managed via Concourse CI (see the `ci` directory). @@ -255,7 +260,7 @@ We prioritize named imports, TypeScript, and Pascal Case component names through ### Application structure -Next.js has few opinions about how to structure applications. We have chosen to use an MVC (Model View Controller)-like pattern. +Next.js has few opinions about how to structure applications. We have chosen to use an MVC (Model View Controller)-like pattern. See our [architecture](docs/dev-practices/architecture.md) documentation for information about each of the layers and how we are using them. diff --git a/__tests__/api/blog/blog.test.js b/__tests__/api/blog/blog.test.js new file mode 100644 index 00000000..6cf0d454 --- /dev/null +++ b/__tests__/api/blog/blog.test.js @@ -0,0 +1,81 @@ +import nock from 'nock'; +import { describe, it, expect, beforeEach, afterEach } from '@jest/globals'; +import { fetchXML, getBlogFeed } from '@/api/blog/blog'; +import fs from 'node:fs'; +import path from 'node:path'; + +const blogFeedXml = fs.readFileSync( + path.join(__dirname, '/../mocks/blogFeed.xml'), + 'utf8' +); + +const blogUrl = new URL(process.env.NEXT_PUBLIC_BLOG_FEED_URL); + +beforeEach(() => { + if (!nock.isActive()) { + nock.activate(); + } +}); + +afterEach(() => { + nock.cleanAll(); + // https://github.com/nock/nock#memory-issues-with-jest + nock.restore(); +}); + +describe('fetchXML', () => { + describe('on success', () => { + it('returns xml as text', async () => { + // setup + nock(blogUrl.origin).get(blogUrl.pathname).reply(200, blogFeedXml); + // act + const result = await fetchXML(blogUrl); + // assert + expect(result).toEqual(blogFeedXml); + }); + }); + describe('on request error', () => { + it('throws error', async () => { + // setup + nock(blogUrl.origin).get(blogUrl.pathname).reply(500); + // act + expect(async () => { + await fetchXML(blogUrl); + }).rejects.toThrow(); + }); + }); +}); + +describe('getBlogFeed', () => { + describe('on success', () => { + it('returns blog feed as parsed json', async () => { + // setup + nock(blogUrl.origin).get(blogUrl.pathname).reply(200, blogFeedXml); + // act + const blog = await getBlogFeed(); + // assert + const post = blog.feed.entry[0]; + const title = post.title._text; + const pubDate = post.published._text; + const link = post.id._text; + const summary = post.summary._cdata; + expect(title).toEqual('August 8th Cloud.gov Release Notes'); + expect(pubDate).toEqual('2024-08-08T00:00:00+00:00'); + expect(link).toEqual('https://cloud.gov/2024/08/08/release-notes'); + expect(summary).toEqual( + 'The Cloud.gov team is working on providing release notes so everyone can see new features and updates.' + ); + }); + }); + + describe('on request error', () => { + it('throws error', async () => { + // setup + nock(blogUrl.origin).get(blogUrl.pathname).reply(500); + // act + expect(async () => { + await getBlogFeed(); + }).rejects.toThrow(); + }); + }); +}); diff --git a/__tests__/api/mocks/blogFeed.xml b/__tests__/api/mocks/blogFeed.xml new file mode 100644 index 00000000..dc413343 --- /dev/null +++ b/__tests__/api/mocks/blogFeed.xml @@ -0,0 +1 @@ +Jekyll2024-10-22T17:19:40+00:00https://cloud.gov/updates.xmlcloud.govExpedite your agency’s path to a secure and compliant cloud. cloud.gov provides an application environment that enables rapid deployment and ATO assessment for modern web applications.August 8th Cloud.gov Release Notes2024-08-08T00:00:00+00:002024-08-08T19:21:35+00:00https://cloud.gov/2024/08/08/release-notesMay 30th Cloud.gov Release Notes2024-05-30T00:00:00+00:002024-05-30T13:50:09+00:00https://cloud.gov/2024/05/30/release-notesMay 16th Cloud.gov Release Notes2024-05-16T00:00:00+00:002024-05-16T17:51:52+00:00https://cloud.gov/2024/05/16/release-notesApril 18th cloud.gov Change Log2024-04-18T00:00:00+00:002024-04-18T17:21:01+00:00https://cloud.gov/2024/04/18/release-notesApril 4th cloud.gov Change Log2024-04-04T00:00:00+00:002024-04-04T13:07:53+00:00https://cloud.gov/2024/04/04/release-notesMarch 21st cloud.gov Change Log2024-03-21T00:00:00+00:002024-05-09T15:04:18+00:00https://cloud.gov/2024/03/21/release-notesMarch 7th cloud.gov Change Log2024-03-07T00:00:00+00:002024-04-08T12:44:28+00:00https://cloud.gov/2024/03/07/release-notesFebruary 23rd cloud.gov Change Log2024-02-23T00:00:00+00:002024-02-23T17:35:47+00:00https://cloud.gov/2024/02/23/release-notesFebruary 8th cloud.gov Change Log2024-02-08T00:00:00+00:002024-02-08T18:12:33+00:00https://cloud.gov/2024/02/08/release-notesJanuary 25th cloud.gov Change Log2024-01-25T00:00:00+00:002024-01-25T13:28:27+00:00https://cloud.gov/2024/01/25/release-notesDecember 29th cloud.gov Change Log2023-12-29T00:00:00+00:002023-12-29T17:51:48+00:00https://cloud.gov/2023/12/29/release-notesDecember 12th cloud.gov Change Log2023-12-12T00:00:00+00:002023-12-12T18:15:41+00:00https://cloud.gov/2023/12/12/release-notesNovember 27th cloud.gov Change Log2023-11-27T00:00:00+00:002023-11-27T16:26:15+00:00https://cloud.gov/2023/11/27/release-notesNew platform protections against malicious activity2023-11-09T00:00:00+00:002023-11-09T21:01:40+00:00https://cloud.gov/2023/11/09/platform-protectionsNovember 9th cloud.gov Change Log2023-11-09T00:00:00+00:002023-11-14T22:57:38+00:00https://cloud.gov/2023/11/09/release-notesUpdating existing instances to gp3 storage volumes is now supported2023-10-11T00:00:00+00:002023-10-11T15:50:42+00:00https://cloud.gov/2023/10/11/update-existing-instances-gp3-now-availablecloud.gov Page’s Federalist ATO Extension2023-09-25T00:00:00+00:002023-09-28T22:26:41+00:00https://cloud.gov/2023/09/25/federalist-atoBrokered storage volumes upgraded to gp32023-09-18T00:00:00+00:002023-09-18T20:59:17+00:00https://cloud.gov/2023/09/18/storage-volumes-gp3Announcing FedRAMP CSP Community mailing list2023-08-01T00:00:00+00:002023-08-01T19:37:53+00:00https://cloud.gov/2023/08/01/fedramp-csp-communityNew versions of PostgreSQL supported2023-08-01T00:00:00+00:002024-08-02T14:06:54+00:00https://cloud.gov/2023/08/01/postgresql-versions-update diff --git a/__tests__/controllers/controllers.test.js b/__tests__/controllers/controllers.test.js index 37e8ba80..3b338efd 100644 --- a/__tests__/controllers/controllers.test.js +++ b/__tests__/controllers/controllers.test.js @@ -1,13 +1,15 @@ import nock from 'nock'; +import { cookies } from 'next/headers'; import { describe, it, expect, beforeEach, afterEach } from '@jest/globals'; import { getEditOrgRoles, - getOrgPage, + getOrgUsersPage, getOrgsPage, getOrgAppsPage, getOrgUsagePage, getUser, removeUserFromOrg, + getOrgLandingpage, } from '@/controllers/controllers'; import { pollForJobCompletion } from '@/controllers/controller-helpers'; import { mockApps } from '../api/mocks/apps'; @@ -16,6 +18,7 @@ import { mockUsersByOrganization, mockUsersBySpace, } from '../api/mocks/roles'; +import { mockOrgs } from '../api/mocks/organizations'; import { getUserLogonInfo } from '@/api/aws/s3'; /* global jest */ @@ -36,6 +39,9 @@ jest.mock('@/controllers/controller-helpers', () => ({ jest.mock('@/api/aws/s3', () => ({ getUserLogonInfo: jest.fn(), })); +jest.mock('next/headers', () => ({ + cookies: jest.fn(), +})); /* eslint no-undef: "error" */ beforeEach(() => { @@ -54,7 +60,7 @@ afterEach(() => { }); describe('controllers tests', () => { - describe('getOrgPage', () => { + describe('getOrgUsersPage', () => { describe('if any of the first CF requests fail', () => { it('throws an error', async () => { // setup @@ -70,7 +76,7 @@ describe('controllers tests', () => { // assert expect(async () => { - await getOrgPage(orgGuid); + await getOrgUsersPage(orgGuid); }).rejects.toThrow(new Error('something went wrong with the request')); }); }); @@ -107,7 +113,7 @@ describe('controllers tests', () => { return undefined; }); - const res = await getOrgPage(orgGuid); + const res = await getOrgUsersPage(orgGuid); // assert expect(res.payload.userLogonInfo).toBeUndefined(); @@ -158,7 +164,7 @@ describe('controllers tests', () => { }; }); - const result = await getOrgPage(orgGuid); + const result = await getOrgUsersPage(orgGuid); const firstUserRoles = result.payload.roles['73193f8c-e03b-43c8-aeee-8670908899d2']; const firstUser = result.payload.users[0]; @@ -283,7 +289,7 @@ describe('controllers tests', () => { .reply(200, mockServiceBindings); // act - const result = await getOrgPage(orgGuid); + const result = await getOrgUsersPage(orgGuid); // assert expect(result).toHaveProperty('meta'); @@ -579,4 +585,76 @@ describe('controllers tests', () => { ]); }); }); + + describe('getHomepage', () => { + it('sets correct orgs, roles, and user counts', async () => { + // setup + nock(process.env.CF_API_URL) + .get(/organizations/) + .reply(200, mockOrgs); + + nock(process.env.CF_API_URL) + .get(/roles/) + .reply(200, mockRolesFilteredByOrgAndUser); + + cookies.mockImplementation(() => ({ + get: () => ({ value: '{"lastViewedOrgId": null}' }), + })); + // act + const result = await getOrgLandingpage(); + // assert orgs + expect(result.payload.orgs.length).toEqual(3); + expect(result.payload.orgs[0].name).toEqual('Org1'); + // assert roles + expect(result.payload.currentUserRoles.length).toEqual(2); + // assert user counts + expect(result.payload.userCounts['orgId2']).toEqual(1); // see mock implementation at top of file + }); + + describe('when a last viewed org id cookie is present', () => { + it('sets current org id to that cookie', async () => { + // setup + nock(process.env.CF_API_URL) + .get(/organizations/) + .reply(200, mockOrgs); + + nock(process.env.CF_API_URL) + .get(/roles/) + .reply(200, mockRolesFilteredByOrgAndUser); + + cookies.mockImplementation(() => ({ + get: () => ({ value: 'f114757b-568a-4291-a389-6b97e6b47c47' }), + })); // guid from mockOrgs + // act + const result = await getOrgLandingpage(); + // assert + expect(result.payload.currentOrgId).toEqual( + 'f114757b-568a-4291-a389-6b97e6b47c47' + ); + }); + }); + + describe('when no last viewed org id cookie', () => { + it('sets current org id to first returned org from orgs list', async () => { + // setup + nock(process.env.CF_API_URL) + .get(/organizations/) + .reply(200, mockOrgs); + + nock(process.env.CF_API_URL) + .get(/roles/) + .reply(200, mockRolesFilteredByOrgAndUser); + + cookies.mockImplementation(() => ({ + get: () => ({ value: null }), + })); // guid from mockOrgs + // act + const result = await getOrgLandingpage(); + // assert + expect(result.payload.currentOrgId).toEqual( + 'b4b52bd5-4940-456a-9432-90c168af6cf8' + ); + }); + }); + }); }); diff --git a/__tests__/middleware.test.js b/__tests__/middleware.test.js index a0d1828f..7d033f25 100644 --- a/__tests__/middleware.test.js +++ b/__tests__/middleware.test.js @@ -2,10 +2,13 @@ import { describe, expect, it, beforeAll, afterEach } from '@jest/globals'; import { NextRequest, NextResponse } from 'next/server'; import jwt from 'jsonwebtoken'; import middleware from '@/middleware.ts'; +import { mockOrgs } from './api/mocks/organizations'; // Need to disable eslint for this import because // you need to import the module you're going to mock with Jest // eslint-disable-next-line no-unused-vars import { postToAuthTokenUrl } from '@/api/auth'; +// eslint-disable-next-line no-unused-vars +import { getOrgs } from '@/api/cf/cloudfoundry'; const mockEmailAddress = 'foo@example.com'; const mockUserName = 'fooUserName'; @@ -25,12 +28,24 @@ const mockAuthResponse = { refresh_token: mockRefreshToken, expires_in: mockExpiry, }; +// eslint-disable-next-line no-undef +const mockOrgsResponse = new Promise((resolve) => + resolve({ + json: async () => { + return mockOrgs; + }, + }) +); /* global jest */ /* eslint no-undef: "off" */ jest.mock('@/api/auth', () => ({ postToAuthTokenUrl: jest.fn(() => mockAuthResponse), })); + +jest.mock('@/api/cf/cloudfoundry', () => ({ + getOrgs: jest.fn(() => mockOrgsResponse), +})); /* eslint no-undef: "error" */ describe('/login', () => { @@ -299,7 +314,7 @@ describe('/orgs/* when logged in', () => { describe('withCSP', () => { it('should modify request headers', async () => { // setup - const request = new NextRequest(new URL('/', process.env.ROOT_URL)); + const request = new NextRequest(new URL('/foobar', process.env.ROOT_URL)); const response = await middleware(request); @@ -308,3 +323,60 @@ describe('withCSP', () => { expect(response.headers.get('x-nonce')).not.toBeNull(); }); }); + +describe('/ (root)', () => { + describe('when authenticated', () => { + describe('when last viewed org id cookie is set', () => { + const request = new NextRequest(new URL('/', process.env.ROOT_URL)); + // setup + let response; + beforeAll(async () => { + request.cookies.set('lastViewedOrgId', 'fooOrgId'); + request.cookies.set( + 'authsession', + JSON.stringify({ + expiry: Date.now() + 10000000, + accessToken: 'foobar', + user_name: 'foo', + email: 'foo', + user_id: 'foo', + }) + ); + // run + response = await middleware(request); + }); + + it('redirects you to /orgs/:lastViewedOrgId', () => { + expect(response.headers.get('location')).toContain('/orgs/fooOrgId'); + }); + }); + + describe('when no last viewed org id cookie is set', () => { + // setup + const request = new NextRequest(new URL('/', process.env.ROOT_URL)); + let response; + beforeAll(async () => { + // setup + request.cookies.set( + 'authsession', + JSON.stringify({ + expiry: Date.now() + 10000000, + accessToken: 'foo', + user_name: 'foo', + email: 'foo', + user_id: 'foo', + }) + ); + + // run + response = await middleware(request); + }); + + it('takes you to first returned org from CF API', () => { + expect(response.headers.get('location')).toContain( + '/orgs/b4b52bd5-4940-456a-9432-90c168af6cf8' + ); // guid of first org in mockOrgs + }); + }); + }); +}); diff --git a/package-lock.json b/package-lock.json index 9a2c9d2b..c4476a1b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,8 @@ "next": "~14.2.15", "pg": "^8.13.0", "react": "18.3.1", - "react-dom": "18.3.1" + "react-dom": "18.3.1", + "xml-js": "^1.6.11" }, "devDependencies": { "@testing-library/jest-dom": "^6.6.2", @@ -11702,6 +11703,12 @@ "node": ">=14.0.0" } }, + "node_modules/sax": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.4.1.tgz", + "integrity": "sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg==", + "license": "ISC" + }, "node_modules/saxes": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", @@ -12967,6 +12974,18 @@ } } }, + "node_modules/xml-js": { + "version": "1.6.11", + "resolved": "https://registry.npmjs.org/xml-js/-/xml-js-1.6.11.tgz", + "integrity": "sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g==", + "license": "MIT", + "dependencies": { + "sax": "^1.2.4" + }, + "bin": { + "xml-js": "bin/cli.js" + } + }, "node_modules/xml-name-validator": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-4.0.0.tgz", diff --git a/package.json b/package.json index 63f1a3e2..78613abb 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,8 @@ "next": "~14.2.15", "pg": "^8.13.0", "react": "18.3.1", - "react-dom": "18.3.1" + "react-dom": "18.3.1", + "xml-js": "^1.6.11" }, "engines": { "node": "20.15.1", diff --git a/public/img/blog_img.png b/public/img/blog_img.png new file mode 100644 index 00000000..21ed3d2b Binary files /dev/null and b/public/img/blog_img.png differ diff --git a/public/img/uswds/usa-icons/assessment.svg b/public/img/uswds/usa-icons/assessment.svg new file mode 100644 index 00000000..520ad6af --- /dev/null +++ b/public/img/uswds/usa-icons/assessment.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/public/img/uswds/usa-icons/help.svg b/public/img/uswds/usa-icons/help.svg new file mode 100644 index 00000000..3ebf817b --- /dev/null +++ b/public/img/uswds/usa-icons/help.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/public/img/uswds/usa-icons/people.svg b/public/img/uswds/usa-icons/people.svg new file mode 100644 index 00000000..6a805e7b --- /dev/null +++ b/public/img/uswds/usa-icons/people.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/api/blog/blog.ts b/src/api/blog/blog.ts new file mode 100644 index 00000000..dc103590 --- /dev/null +++ b/src/api/blog/blog.ts @@ -0,0 +1,57 @@ +/***/ +// API library for basic error handling and serialization +/***/ + +import { request } from '@/api/api'; +import { logDevError } from '@/controllers/controller-helpers'; +import convert from 'xml-js'; + +export interface BlogEntryObj { + title: { + _text: string; + }; + published: { + _text: string; + }; + id: { + _text: string; + }; + summary: { + _cdata: string; + }; +} + +export interface BlogObj { + feed: { + entry: BlogEntryObj[]; + }; +} + +export async function fetchXML(url: string): Promise { + try { + const response = await request(url); + if (!response.ok) { + throw new Error(`HTTP error, status: ${response.status}`); + } + return await response.text(); + } catch (error) { + const msg = `Error fetching or parsing XML: ${error}`; + logDevError(msg); + throw new Error(msg); + } +} + +export async function getBlogFeed(): Promise { + try { + if (!process.env.NEXT_PUBLIC_BLOG_FEED_URL) { + throw new Error('blog feed url environment variable is not set'); + } + const xml = await fetchXML(process.env.NEXT_PUBLIC_BLOG_FEED_URL); + const jsonString = convert.xml2json(xml, { compact: true }); + return JSON.parse(jsonString); + } catch (error) { + const msg = `Error fetching or parsing blog feed: ${error}`; + logDevError(msg); + throw new Error(msg); + } +} diff --git a/src/app/orgs/error.tsx b/src/app/error.tsx similarity index 100% rename from src/app/orgs/error.tsx rename to src/app/error.tsx diff --git a/src/app/layout.js b/src/app/layout.js index 03b5e375..12d99530 100644 --- a/src/app/layout.js +++ b/src/app/layout.js @@ -2,6 +2,7 @@ import { Banner } from '@/components/uswds/Banner'; import { Footer } from '@/components/uswds/Footer'; import { Identifier } from '@/components/uswds/Identifier'; import { NavGlobal } from '@/components/NavGlobal/NavGlobal'; +import { PreFooter } from '@/components/PreFooter'; import '@/assets/stylesheets/styles.scss'; @@ -20,8 +21,11 @@ export default function RootLayout({ children }) {
-
{children}
+
+
{children}
+
+