diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..a518f3e --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,16 @@ +name: Run unit tests + +on: push + +jobs: + unit-test: + runs-on: ubuntu-20.04 + steps: + - uses: actions/checkout@v2 + - uses: actions/setup-node@v4 + with: + node-version: 18 + - run: | + cd app + yarn + yarn test diff --git a/app/.env.example b/app/.env.example index dd792e5..60eddba 100644 --- a/app/.env.example +++ b/app/.env.example @@ -39,4 +39,5 @@ TF_GH_REPO= TF_MODULE_GH_REF= GH_ACCESS_TOKEN= GH_API_TOKEN= +DATABASE_URL= IDIR_REQUESTOR_USER_GUID= diff --git a/app/__tests__/custom-realm-dashboard.test.tsx b/app/__tests__/custom-realm-dashboard.test.tsx new file mode 100644 index 0000000..da3faea --- /dev/null +++ b/app/__tests__/custom-realm-dashboard.test.tsx @@ -0,0 +1,237 @@ +import React from 'react'; +import { render, screen, within, waitFor } from '@testing-library/react'; +import App from 'pages/_app'; +import CustomRealmDashboard from 'pages/custom-realm-dashboard'; +import { updateRealmProfile } from 'services/realm'; +import { CustomRealmFormData } from 'types/realm-profile'; +import Router from 'next/router'; + +jest.mock('services/realm', () => { + return { + deleteRealmRequest: jest.fn((realmInfo: CustomRealmFormData) => Promise.resolve([true, null])), + updateRealmProfile: jest.fn((id: number, status: string) => Promise.resolve([true, null])), + }; +}); + +jest.mock('next/router', () => ({ + useRouter() { + return { + route: '/', + pathname: '', + query: '', + asPath: '', + push: jest.fn(() => Promise.resolve(true)), + events: { + on: jest.fn(), + off: jest.fn(), + }, + beforePopState: jest.fn(() => null), + prefetch: jest.fn(() => null), + }; + }, +})); + +// Mock authentication +const mockSession = { + expires: new Date(Date.now() + 2 * 86400).toISOString(), + user: { username: 'admin' }, +}; +jest.mock('next-auth/react', () => { + const originalModule = jest.requireActual('next-auth/react'); + return { + __esModule: true, + ...originalModule, + useSession: jest.fn(() => { + return { data: mockSession, status: 'authenticated' }; // return type is [] in v3 but changed to {} in v4 + }), + }; +}); + +jest.mock('next-auth/next', () => { + return { + __esModule: true, + getServerSession: jest.fn(() => { + return { data: mockSession, status: 'authenticated' }; + }), + }; +}); + +const defaultData: CustomRealmFormData[] = [ + { + id: 1, + realm: 'realm 1', + purpose: 'purpose', + primaryEndUsers: ['livingInBC', 'businessInBC', 'govEmployees', 'details'], + environments: ['dev', 'test', 'prod'], + preferredAdminLoginMethod: 'idir', + productOwnerEmail: 'a@b.com', + productOwnerIdirUserId: 'me', + technicalContactEmail: 'b@c.com', + technicalContactIdirUserId: 'd@e.com', + secondTechnicalContactIdirUserId: 'dmsd', + secondTechnicalContactEmail: 'dksadlks@fkjlsdj.com', + status: 'pending', + approved: null, + }, + { + id: 2, + realm: 'realm 2', + purpose: 'purpose', + primaryEndUsers: ['livingInBC', 'businessInBC', 'govEmployees', 'details'], + environments: ['dev', 'test', 'prod'], + preferredAdminLoginMethod: 'idir', + productOwnerEmail: 'a@b.com', + productOwnerIdirUserId: 'me', + technicalContactEmail: 'b@c.com', + technicalContactIdirUserId: 'd@e.com', + secondTechnicalContactIdirUserId: 'dmsd', + secondTechnicalContactEmail: 'dksadlks@fkjlsdj.com', + status: 'pending', + approved: null, + }, +]; + +jest.mock('../pages/api/realms', () => { + return { + __esModule: true, + getAllRealms: jest.fn(() => Promise.resolve([defaultData, null])), + authOptions: {}, + }; +}); + +jest.mock('../pages/api/auth/[...nextauth]', () => { + return { + __esModule: true, + authOptions: {}, + }; +}); + +describe('Table', () => { + it('Loads in table data from serverside props', () => { + render(); + const table = screen.getByTestId('custom-realm-table'); + expect(within(table).getByText('realm 1')); + expect(within(table).getByText('realm 2')); + }); +}); + +describe('Status update', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('Prompts modal for request approval', async () => { + render( + , + ); + + screen.getByText('Access Request').click(); + await waitFor(() => screen.getByText('Approve Custom Realm', { selector: 'button' }).click()); + await waitFor(() => screen.getByText('Are you sure you want to approve request 1?')); + }); + + it('Prompts modal for request declination', async () => { + render( + , + ); + screen.getByText('Access Request').click(); + await waitFor(() => screen.getByText('Decline Custom Realm', { selector: 'button' }).click()); + await waitFor(() => screen.getByText('Are you sure you want to decline request 1?')); + }); + + it('Fires expected api request when approving', async () => { + render( + , + ); + screen.getByText('Access Request').click(); + await waitFor(() => screen.getByText('Approve Custom Realm', { selector: 'button' }).click()); + await waitFor(() => screen.getByText('Are you sure you want to approve request 1?')); + screen.getByText('Confirm', { selector: 'button' }).click(); + + const payload = (updateRealmProfile as jest.Mock).mock.calls[0][1]; + expect(payload.approved).toBeTruthy(); + }); + + it('Fires expected api request when declining', async () => { + render( + , + ); + screen.getByText('Access Request').click(); + await waitFor(() => screen.getByText('Decline Custom Realm', { selector: 'button' }).click()); + await waitFor(() => screen.getByText('Are you sure you want to decline request 1?')); + screen.getByText('Confirm', { selector: 'button' }).click(); + + const payload = (updateRealmProfile as jest.Mock).mock.calls[0][1]; + expect(payload.approved).toBeFalsy(); + }); + + it('Updates status in table only when successfully approved', async () => { + render( + , + ); + (updateRealmProfile as jest.MockedFunction).mockImplementationOnce(() => + Promise.resolve([null, { message: 'failure' }]), + ); + screen.getByText('Access Request').click(); + await waitFor(() => screen.getByText('Approve Custom Realm', { selector: 'button' }).click()); + await waitFor(() => screen.getByText('Are you sure you want to approve request 1?')); + screen.getByText('Confirm', { selector: 'button' }).click(); + + // Still pending + const firstRow = screen.getByTestId('custom-realm-row-1'); + within(firstRow).getByText('pending'); + + // Successful request + (updateRealmProfile as jest.MockedFunction).mockImplementationOnce(() => Promise.resolve([true, null])); + screen.getByText('Approve Custom Realm', { selector: 'button' }).click(); + screen.getByText('Confirm', { selector: 'button' }).click(); + await waitFor(() => within(firstRow).getByText('Approved')); + }); + + it('Updates status in table only when successfully declined', async () => { + render( + , + ); + (updateRealmProfile as jest.MockedFunction).mockImplementationOnce(() => + Promise.resolve([null, { message: 'failure' }]), + ); + screen.getByText('Access Request').click(); + await waitFor(() => screen.getByText('Decline Custom Realm', { selector: 'button' }).click()); + await waitFor(() => screen.getByText('Are you sure you want to decline request 1?')); + screen.getByText('Confirm', { selector: 'button' }).click(); + + // Still pending + const firstRow = screen.getByTestId('custom-realm-row-1'); + within(firstRow).getByText('pending'); + + // Successful request + (updateRealmProfile as jest.MockedFunction).mockImplementationOnce(() => Promise.resolve([true, null])); + screen.getByText('Decline Custom Realm', { selector: 'button' }).click(); + screen.getByText('Confirm', { selector: 'button' }).click(); + await waitFor(() => within(firstRow).getByText('Declined')); + }); +}); diff --git a/app/__tests__/custom-realm-form.test.tsx b/app/__tests__/custom-realm-form.test.tsx new file mode 100644 index 0000000..736eec0 --- /dev/null +++ b/app/__tests__/custom-realm-form.test.tsx @@ -0,0 +1,142 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import CustomRealmForm from 'pages/custom-realm-form'; +import { submitRealmRequest } from 'services/realm'; +import { CustomRealmFormData } from 'types/realm-profile'; +import { act } from 'react-dom/test-utils'; + +jest.mock('services/realm', () => { + return { + submitRealmRequest: jest.fn((realmInfo: CustomRealmFormData) => Promise.resolve([true, null])), + }; +}); + +jest.mock('next/router', () => ({ + useRouter() { + return { + route: '/', + pathname: '', + query: '', + asPath: '', + push: jest.fn(() => Promise.resolve(true)), + events: { + on: jest.fn(), + off: jest.fn(), + }, + beforePopState: jest.fn(() => null), + prefetch: jest.fn(() => null), + }; + }, +})); + +// Mock authentication +jest.mock('next-auth/react', () => { + const originalModule = jest.requireActual('next-auth/react'); + const mockSession = { + expires: new Date(Date.now() + 2 * 86400).toISOString(), + user: { username: 'admin' }, + }; + return { + __esModule: true, + ...originalModule, + useSession: jest.fn(() => { + return { data: mockSession, status: 'authenticated' }; // return type is [] in v3 but changed to {} in v4 + }), + }; +}); + +describe('Form Validation', () => { + const requiredFieldCount = 8; + + const submitForm = () => { + const submitButon = screen.getByText('Submit', { selector: 'button' }); + fireEvent.click(submitButon); + }; + + const getErrorCount = (container: HTMLElement) => { + const errorText = container.querySelectorAll('.error-message'); + return Array.from(errorText).length; + }; + + const fillTextInput = (label: string, value = 'a') => { + const field = screen.getByLabelText(label, { exact: false }); + fireEvent.change(field, { target: { value } }); + }; + + const clickInput = (label: string) => { + const field = screen.getByLabelText(label); + fireEvent.click(field); + }; + + it('Shows validation messages for incomplete fields and does not make api request', () => { + const { container } = render(); + submitForm(); + + const errorCount = getErrorCount(container); + expect(errorCount).toBe(requiredFieldCount); + expect(submitRealmRequest).not.toHaveBeenCalled(); + }); + + it('Clears out validation messages as fields are completed', () => { + // Trigger all errors + const { container } = render(); + submitForm(); + fillTextInput('1. Custom Realm name'); + expect(getErrorCount(container)).toBe(requiredFieldCount - 1); + + fillTextInput('2. Purpose of Realm'); + expect(getErrorCount(container)).toBe(requiredFieldCount - 2); + + // Primary users section + clickInput('People living in BC'); + expect(getErrorCount(container)).toBe(requiredFieldCount - 3); + + // Environments section + clickInput('Development'); + expect(getErrorCount(container)).toBe(requiredFieldCount - 4); + + fillTextInput("5. Product owner's email"); + expect(getErrorCount(container)).toBe(requiredFieldCount - 5); + + fillTextInput("6. Product owner's IDIR"); + expect(getErrorCount(container)).toBe(requiredFieldCount - 6); + + fillTextInput("7. Technical contact's email"); + expect(getErrorCount(container)).toBe(requiredFieldCount - 7); + + fillTextInput("8. Technical contact's IDIR"); + expect(getErrorCount(container)).toBe(requiredFieldCount - 8); + }); + + it('Sends off the expected form data when a proper submission is made', async () => { + render(); + fillTextInput('1. Custom Realm name', 'name'); + fillTextInput('2. Purpose of Realm', 'purpose'); + clickInput('People living in BC'); + clickInput('Development'); + fillTextInput("5. Product owner's email", 'po@gmail.com'); + fillTextInput("6. Product owner's IDIR", 'poidir'); + fillTextInput("7. Technical contact's email", 'tc@gmail.com'); + fillTextInput("8. Technical contact's IDIR", 'tcidir'); + fillTextInput("9. Secondary technical contact's email", 'stc@gmail.com'); + fillTextInput("10. Secondary technical contact's IDIR", 'stcidir'); + + await act(async () => { + submitForm(); + }); + + expect(submitRealmRequest).toHaveBeenCalledWith({ + environments: ['dev'], + primaryEndUsers: ['livingInBC'], + productOwnerEmail: 'po@gmail.com', + productOwnerIdirUserId: 'poidir', + realm: 'name', + purpose: 'purpose', + secondTechnicalContactEmail: 'stc@gmail.com', + secondTechnicalContactIdirUserId: 'stcidir', + status: 'pending', + technicalContactEmail: 'tc@gmail.com', + technicalContactIdirUserId: 'tcidir', + }); + }); +}); diff --git a/app/components/Modal.tsx b/app/components/Modal.tsx new file mode 100644 index 0000000..c555e11 --- /dev/null +++ b/app/components/Modal.tsx @@ -0,0 +1,134 @@ +import styled from 'styled-components'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faCheckCircle, faClose } from '@fortawesome/free-solid-svg-icons'; +import { ModalConfig } from 'context/modal'; +import Button from '@button-inc/bcgov-theme/Button'; +import { Grid as SpinnerGrid } from 'react-loader-spinner'; +import { useState } from 'react'; + +const Modal = styled.div` + position: fixed; + z-index: 10; + top: 0; + left: 0; + + .background { + background-color: #dadada; + opacity: 0.6; + height: 100vh; + width: 100vw; + } + + .content { + position: absolute; + display: block; + left: 50%; + top: 50%; + width: 35em; + transform: translate(-50%, -50%); + background: white; + + border-radius: 0.5rem 0.5rem 0 0; + box-shadow: rgba(0, 0, 0, 0.15) -2px 2px 2.5px; + + .header-text { + margin-left: 1em; + } + + .header { + background-color: #38598a; + display: flex; + flex-direction: row; + justify-content: space-between; + height: 3em; + padding: 1rem; + + align-items: center; + color: white; + font-weight: bold; + font-size: 1.2rem; + + p { + padding: 0; + margin: 0; + } + .exit { + cursor: pointer; + } + } + .body { + padding: 1rem; + } + + .button-container { + width: 100%; + display: flex; + flex-direction: row; + justify-content: space-between; + margin-top: 2em; + + button { + width: 10em; + } + } + } +`; + +interface Props { + modalConfig: ModalConfig; + setModalConfig: (config: ModalConfig) => void; +} + +export default function GlobalModal({ setModalConfig, modalConfig }: Props) { + const clearModal = () => setModalConfig({ ...modalConfig, show: false }); + const [waiting, setWaiting] = useState(false); + const { showCancelButton, showConfirmButton, onConfirm } = modalConfig; + const hasButtons = showCancelButton || showConfirmButton; + + const onConfirmClick = async () => { + if (!onConfirm) return; + setWaiting(true); + try { + await onConfirm(); + } catch (e) { + } finally { + setWaiting(false); + clearModal(); + } + }; + + return ( + +
+
+
+
+ + {modalConfig.title} +
+ +
+
+ {modalConfig.body} + {hasButtons && ( +
+ {/* Include empty span if missing for layout purposes */} + {showCancelButton ? ( + + ) : ( + + )} + {showConfirmButton && ( + + )} +
+ )} +
+
+ + ); +} diff --git a/app/context/modal.ts b/app/context/modal.ts new file mode 100644 index 0000000..8943047 --- /dev/null +++ b/app/context/modal.ts @@ -0,0 +1,19 @@ +import { createContext } from 'react'; + +export interface ModalConfig { + show: boolean; + title: string; + body: string; + showConfirmButton?: boolean; + showCancelButton?: boolean; + onConfirm?: () => Promise; +} + +export const ModalContext = createContext({ + modalConfig: { + show: false, + title: '', + body: '', + }, + setModalConfig: (config: ModalConfig) => {}, +}); diff --git a/app/jest.config.js b/app/jest.config.js new file mode 100644 index 0000000..af3e0d3 --- /dev/null +++ b/app/jest.config.js @@ -0,0 +1,18 @@ +const nextJest = require('next/jest.js'); + +const createJestConfig = nextJest({ + // Provide the path to your Next.js app to load next.config.js and .env files in your test environment + dir: './', +}); + +// Add any custom config to be passed to Jest +/** @type {import('jest').Config} */ +const config = { + // Add more setup options before each test is run + setupFilesAfterEnv: ['/jest.setup.js'], + testEnvironment: 'jest-environment-jsdom', + preset: 'ts-jest', + moduleDirectories: ['', 'node_modules'], +}; + +module.exports = createJestConfig(config); diff --git a/app/jest.setup.js b/app/jest.setup.js new file mode 100644 index 0000000..7b0828b --- /dev/null +++ b/app/jest.setup.js @@ -0,0 +1 @@ +import '@testing-library/jest-dom'; diff --git a/app/layout/Layout.tsx b/app/layout/Layout.tsx index fa4bf29..df88cd5 100644 --- a/app/layout/Layout.tsx +++ b/app/layout/Layout.tsx @@ -106,6 +106,8 @@ interface Route { const routes: Route[] = [ { path: '/', label: 'Home', roles: ['guest', 'user', 'sso-admin'] }, { path: '/my-dashboard', label: 'My Dashboard', roles: ['user', 'sso-admin'] }, + { path: '/custom-realm-form', label: 'Request Custom Realm', roles: ['sso-admin', 'user'] }, + { path: '/custom-realm-dashboard', label: 'Custom Realm Dashboard', roles: ['sso-admin'] }, { path: '/realm', label: 'Realm Profile', roles: ['user'], hide: true }, ]; diff --git a/app/package.json b/app/package.json index f8511b0..3a7abe7 100644 --- a/app/package.json +++ b/app/package.json @@ -7,6 +7,8 @@ "build": "next build", "start": "next start", "lint": "next lint", + "test": "jest --silent", + "test:watch": "jest --watchAll", "postinstall": "prisma generate" }, "dependencies": { @@ -27,6 +29,7 @@ "jwk-to-pem": "^2.0.5", "jws": "^4.0.0", "keycloak-admin": "^1.14.22", + "lodash.clonedeep": "^4.5.0", "lodash.get": "^4.4.2", "lodash.isstring": "^4.0.1", "lodash.kebabcase": "^4.1.1", @@ -56,11 +59,15 @@ }, "devDependencies": { "@octokit/types": "^12.1.1", + "@testing-library/jest-dom": "^6.1.4", + "@testing-library/react": "^14.0.0", "@types/deep-diff": "^1.0.5", "@types/easy-soap-request": "^4.1.1", + "@types/jest": "^29.5.7", "@types/jsonwebtoken": "^8.5.8", "@types/jwk-to-pem": "^2.0.1", "@types/jws": "^3.2.4", + "@types/lodash.clonedeep": "^4.5.9", "@types/lodash.get": "^4.4.7", "@types/lodash.isstring": "^4.0.8", "@types/lodash.kebabcase": "^4.1.9", @@ -76,6 +83,9 @@ "@types/yup": "^0.32.0", "eslint": "^8.16.0", "eslint-config-next": "12.1.6", + "jest": "^29.7.0", + "jest-environment-jsdom": "^29.7.0", + "ts-jest": "^29.1.1", "node-mocks-http": "^1.13.0", "prettier": "^3.0.3", "prettier-plugin-prisma": "^5.0.0", diff --git a/app/page-partials/custom-realm-dashboard/CustomRealmTabs.tsx b/app/page-partials/custom-realm-dashboard/CustomRealmTabs.tsx new file mode 100644 index 0000000..d58c198 --- /dev/null +++ b/app/page-partials/custom-realm-dashboard/CustomRealmTabs.tsx @@ -0,0 +1,176 @@ +import styled from 'styled-components'; +import React, { useState } from 'react'; +import { CustomRealmFormData } from 'types/realm-profile'; +import { faCircleCheck, faSpinner } from '@fortawesome/free-solid-svg-icons'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import Button from '@button-inc/bcgov-theme/Button'; + +const Tabs = styled.ul` + display: flex; + flex-direction: row; + list-style-type: none; + border-bottom: 1px solid grey; + margin: 0; + + li { + margin: 0 1em; + &:hover { + cursor: pointer; + } + &.selected { + font-weight: bold; + } + &:first-child { + margin-left: 0; + } + } +`; + +const TabPanel = styled.div` + margin-top: 1em; + .button-container { + button { + margin-right: 1em; + } + } +`; + +const SApprovalList = styled.ul` + list-style-type: none; + margin: 0; + width: 100%; + + .help-text { + color: grey; + font-size: 0.8em; + margin: 0; + margin-bottom: 1em; + } + + .title { + margin: 0; + } + + li { + border-bottom: 1px solid black; + width: 100%; + display: flex; + flex-direction: row; + justify-content: space-between; + margin-bottom: 0; + align-items: center; + margin-top: 0.3em; + } +`; + +const realmCreationFailedStatuses = ['PrFailed', 'planFailed', 'applyFailed']; + +const realmCreatingStatuses = ['pending', 'prSuccess', 'planned']; + +/** + * Return an object with formatted key values to display the details + */ +const formatRealmData = (realm?: CustomRealmFormData) => { + if (!realm) return null; + + return { + Name: realm.realm, + Purpose: realm.purpose, + 'Primary end users': realm.primaryEndUsers.join(', '), + Environments: realm.environments.join(', '), + "Product owner's email": realm.productOwnerEmail, + "Product owner's IDIR": realm.productOwnerIdirUserId, + "Technical contact's email": realm.technicalContactEmail, + "Technical contact's IDIR": realm.technicalContactIdirUserId, + "Secondary technical contact's email": realm.secondTechnicalContactEmail, + "Secondary technical contact's IDIR": realm.secondTechnicalContactIdirUserId, + }; +}; + +interface Props { + selectedRow: CustomRealmFormData; + lastUpdateTime: Date; +} + +function ApprovalList({ selectedRow, lastUpdateTime }: Props) { + if (selectedRow.approved === false) return

This request has been declined.

; + + return ( + <> + {[...realmCreatingStatuses, 'applied'].includes(selectedRow.status) && ( + +

Approval process initiated.

+

+ Last updated at {lastUpdateTime.toLocaleDateString()}, {lastUpdateTime.toLocaleTimeString()} +

+
  • + SSO Approval + +
  • +
  • + Access Custom Realm + {selectedRow.status === 'applied' ? ( + + ) : ( + + )} +
  • +
    + )} + {realmCreationFailedStatuses.includes(selectedRow.status) && ( +

    This request is in a failed state: {selectedRow.status}

    + )} + + ); +} + +const tabs = ['Details', 'Access Request']; + +interface CRTProps extends Props { + handleRequestStatusChange: (status: 'approved' | 'declined', row: CustomRealmFormData) => void; +} + +export default function CutsomRealmTabs({ selectedRow, handleRequestStatusChange, lastUpdateTime }: CRTProps) { + const [selectedTab, setSelectedTab] = useState('Details'); + const formattedRealmData = formatRealmData(selectedRow); + return ( + <> +

    Custom Realm Details

    + + {tabs.map((tab) => ( +
  • setSelectedTab(tab)} key={tab} className={tab === selectedTab ? 'selected' : ''}> + {tab} +
  • + ))} +
    + {/* Only display details if formattedRealmData is not null */} + {formattedRealmData && selectedTab === 'Details' && ( + + {Object.entries(formattedRealmData).map(([label, value]) => ( +
    + {label}: {value} +
    +
    + ))} +
    + )} + {selectedTab === 'Access Request' && ( + + {selectedRow.approved === null ? ( + <> +

    To begin the approval process for this Custom Realm, click below.

    +
    + + +
    + + ) : ( + + )} +
    + )} + + ); +} diff --git a/app/pages/_app.tsx b/app/pages/_app.tsx index 33d3e4a..39bb9ef 100644 --- a/app/pages/_app.tsx +++ b/app/pages/_app.tsx @@ -1,14 +1,21 @@ import 'bootstrap/dist/css/bootstrap.min.css'; import '../styles/globals.css'; -import React from 'react'; +import React, { useState, createContext } from 'react'; import { useRouter } from 'next/router'; import type { AppProps } from 'next/app'; import Head from 'next/head'; import Layout from 'layout/Layout'; import { SessionProvider, signOut, signIn } from 'next-auth/react'; +import Modal from 'components/Modal'; +import { ModalContext, ModalConfig } from 'context/modal'; function MyApp({ Component, pageProps: { session, ...pageProps } }: AppProps) { const router = useRouter(); + const [modalConfig, setModalConfig] = useState({ + show: false, + title: '', + body: '', + }); const handleLogin = async () => { signIn('keycloak', { @@ -25,17 +32,20 @@ function MyApp({ Component, pageProps: { session, ...pageProps } }: AppProps) { }; return ( - - - - - Keycloak Realm Registry - - - - - - + + + {modalConfig.show && } + + + + Keycloak Realm Registry + + + + + + + ); } export default MyApp; diff --git a/app/pages/api/realms.ts b/app/pages/api/realms.ts index 9890dfc..e558cd3 100644 --- a/app/pages/api/realms.ts +++ b/app/pages/api/realms.ts @@ -17,6 +17,57 @@ interface ErrorData { type Data = ErrorData | string; +export const getAllRealms = async (username: string, isAdmin: boolean) => { + let rosters: any = null; + + if (isAdmin) { + rosters = await prisma.roster.findMany(); + } else { + rosters = await prisma.roster.findMany({ + where: { + OR: [ + { + technicalContactIdirUserId: { + equals: username, + mode: 'insensitive', + }, + }, + { + secondTechnicalContactIdirUserId: { + equals: username, + mode: 'insensitive', + }, + }, + { + productOwnerIdirUserId: { + equals: username, + mode: 'insensitive', + }, + }, + ], + }, + }); + } + + const kcCore = new KeycloakCore('prod'); + + if (rosters?.length > 0) { + const kcAdminClient = await kcCore.getAdminClient(); + if (kcAdminClient) { + for (let x = 0; x < rosters?.length; x++) { + const realm = rosters[x]; + const [realmData] = await Promise.all([kcCore.getRealm(realm.realm)]); + realm.idps = realmData?.identityProviders?.map((v) => v.displayName || v.alias) || []; + const distinctProviders = new Set(realmData?.identityProviders?.map((v) => v.providerId) || []); + realm.protocol = Array.from(distinctProviders); + } + } + } + + rosters = !isAdmin ? rosters.map((r: any) => omit(r, adminOnlyFields)) : rosters; + return rosters; +}; + export default async function handler(req: NextApiRequest, res: NextApiResponse) { let username; try { @@ -27,55 +78,9 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) const isAdmin = checkAdminRole(session?.user); if (req.method === 'GET') { - let rosters: any = null; - - if (isAdmin) { - rosters = await prisma.roster.findMany(); - } else { - rosters = await prisma.roster.findMany({ - where: { - OR: [ - { - technicalContactIdirUserId: { - equals: username, - mode: 'insensitive', - }, - }, - { - secondTechnicalContactIdirUserId: { - equals: username, - mode: 'insensitive', - }, - }, - { - productOwnerIdirUserId: { - equals: username, - mode: 'insensitive', - }, - }, - ], - }, - }); - } - - const kcCore = new KeycloakCore('prod'); - - if (rosters?.length > 0) { - const kcAdminClient = await kcCore.getAdminClient(); - if (kcAdminClient) { - for (let x = 0; x < rosters?.length; x++) { - const realm = rosters[x]; - const [realmData] = await Promise.all([kcCore.getRealm(realm.realm)]); - realm.idps = realmData?.identityProviders?.map((v) => v.displayName || v.alias) || []; - const distinctProviders = new Set(realmData?.identityProviders?.map((v) => v.providerId) || []); - realm.protocol = Array.from(distinctProviders); - } - } - } - - rosters = !isAdmin ? rosters.map((r: any) => omit(r, adminOnlyFields)) : rosters; - - return res.send(rosters); + const rosters = await getAllRealms(username, isAdmin); + res.send(rosters); + return; } else if (req.method === 'POST') { let data = req.body; data.realm = kebabCase(data.realm); @@ -93,13 +98,14 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) }); if (existingRealm.length > 0) { - return res.status(400).json({ success: false, error: 'Realm already exists' }); + return res.status(400).json({ success: false, error: 'Realm name already taken' }); } let newRealm = await prisma.roster.create({ data: { ...data, requestor: `${session.user.family_name}, ${session.user.given_name}`, + preferredAdminLoginMethod: 'idir', lastUpdatedBy: `${session.user.family_name}, ${session.user.given_name}`, status: StatusEnum.PENDING, }, diff --git a/app/pages/custom-realm-dashboard.tsx b/app/pages/custom-realm-dashboard.tsx new file mode 100644 index 0000000..02f6af7 --- /dev/null +++ b/app/pages/custom-realm-dashboard.tsx @@ -0,0 +1,320 @@ +import React, { useContext, useEffect, useState } from 'react'; +import styled from 'styled-components'; +import { getCoreRowModel, useReactTable, flexRender, createColumnHelper } from '@tanstack/react-table'; +import { CustomRealmFormData, RealmProfile } from 'types/realm-profile'; +import { faTrash } from '@fortawesome/free-solid-svg-icons'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { ModalContext } from 'context/modal'; +import { withBottomAlert, BottomAlert } from 'layout/BottomAlert'; +import { getRealmProfiles, deleteRealmRequest, updateRealmProfile } from 'services/realm'; +import { getServerSession } from 'next-auth/next'; +import { authOptions } from './api/auth/[...nextauth]'; +import { GetServerSidePropsContext } from 'next'; +import { checkAdminRole } from 'utils/helpers'; +import { getAllRealms } from 'pages/api/realms'; +import CustomRealmTabs from 'page-partials/custom-realm-dashboard/CustomRealmTabs'; + +const Container = styled.div` + padding: 2em; +`; + +const bgGrey = '#ededed'; +const selectedRowBg = '#4950fa'; +const hoverRowBg = '#fdb913'; + +const Table = styled.table` + background-color: ${bgGrey}; + border-collapse: separate; + padding: 1em; + border-spacing: 0 0.5em; + + thead { + tr { + td, + th { + border-bottom: none; + } + } + } + + tbody { + tr { + background-color: white; + td, + th { + padding: 0.5em 1em; + } + + &:hover { + background-color: ${hoverRowBg}; + color: white; + cursor: pointer; + } + + td:last-child { + border-bottom-right-radius: 0.2em; + border-top-right-radius: 0.2em; + } + + td:first-child { + border-bottom-left-radius: 0.2em; + border-top-left-radius: 0.2em; + } + + &.selected { + background-color: ${selectedRowBg}; + color: white; + font-weight: bold; + } + + .delete-icon:hover { + color: red; + } + } + } +`; + +const columnHelper = createColumnHelper(); +interface Props { + defaultRealmRequests: CustomRealmFormData[]; + alert: BottomAlert; +} + +const realmCreatingStatuses = ['pending', 'prSuccess', 'planned']; + +function CustomRealmDashboard({ defaultRealmRequests, alert }: Props) { + const [realmRequests, setRealmRequests] = useState(defaultRealmRequests || []); + const [selectedRow, setSelectedRow] = useState(defaultRealmRequests[0]); + const [lastUpdateTime, setLastUpdateTime] = useState(new Date()); + const { setModalConfig } = useContext(ModalContext); + + // To Add once api in place + // const handleDeleteRequest = (id: number) => { + // const handleConfirm = async () => { + // const [, err] = await deleteRealmRequest(id); + // if (err) { + // return alert.show({ + // variant: 'danger', + // fadeOut: 3500, + // closable: true, + // content: `Network error when deleting request id ${id}. Please try again.`, + // }); + // } + // alert.show({ + // fadeOut: 3500, + // closable: true, + // content: `Deleted request id ${id} successfully.`, + // }); + // const remainingRealms = realmRequests.filter((realm) => realm.id !== id); + // setRealmRequests(remainingRealms); + // setSelectedRow(remainingRealms[0]); + // }; + // setModalConfig({ + // show: true, + // title: 'Delete Realm Request', + // body: `Are you sure you want to delete request ${id}?`, + // showCancelButton: true, + // showConfirmButton: true, + // onConfirm: handleConfirm, + // }); + // }; + + const handleRequestStatusChange = (approval: 'approved' | 'declined', realm: CustomRealmFormData) => { + const realmId = realm.id; + const approving = approval === 'approved'; + const handleConfirm = async () => { + const [, err] = await updateRealmProfile(String(realmId), { + ...realm, + approved: approving, + } as unknown as RealmProfile); + if (err) { + return alert.show({ + variant: 'danger', + fadeOut: 3500, + closable: true, + content: `Network error when updating request id ${realmId}. Please try again.`, + }); + } + alert.show({ + variant: 'success', + fadeOut: 3500, + closable: true, + content: `Request id ${realmId} ${approval}.`, + }); + const updatedRealms = realmRequests.map((realm) => { + if (realm.id === realmId) return { ...realm, approved: approving } as CustomRealmFormData; + return realm; + }); + setRealmRequests(updatedRealms); + setSelectedRow({ ...selectedRow, approved: approving } as CustomRealmFormData); + }; + const statusVerb = approval === 'approved' ? 'Approve' : 'Decline'; + setModalConfig({ + show: true, + title: `${statusVerb} Realm Request`, + body: `Are you sure you want to ${statusVerb.toLocaleLowerCase()} request ${realmId}?`, + showCancelButton: true, + showConfirmButton: true, + onConfirm: handleConfirm, + }); + }; + + const columns = [ + columnHelper.accessor('id', { + header: () => 'Custom Realm ID', + cell: (info) => info.getValue(), + }), + columnHelper.accessor('realm', { + header: () => 'Custom Realm Name', + cell: (info) => info.getValue(), + }), + columnHelper.accessor('productOwnerEmail', { + header: () => 'Product Owner', + cell: (info) => info.renderValue(), + }), + columnHelper.accessor('technicalContactEmail', { + header: () => 'Technical Contact', + cell: (info) => info.renderValue(), + }), + columnHelper.accessor('status', { + header: 'Request Status', + cell: (info) => info.renderValue(), + }), + columnHelper.accessor('approved', { + header: 'Approval Status', + cell: (info) => { + const approved = info.renderValue(); + if (approved === null) return 'Undecided'; + return approved ? 'Approved' : 'Declined'; + }, + }), + columnHelper.display({ + header: 'Actions', + cell: (props) => ( + handleDeleteRequest(props.row.getValue('id'))} + icon={faTrash} + className="delete-icon" + role="button" + data-testid="delete-btn" + /> + ), + }), + ]; + + const table = useReactTable({ + data: realmRequests, + columns, + getCoreRowModel: getCoreRowModel(), + }); + + const fetchRealms = async () => { + // Intentionally not flashing error since this is a background fetch. + const [profiles, err] = await getRealmProfiles(); + if (profiles) { + setLastUpdateTime(new Date()); + setRealmRequests(profiles); + if (selectedRow) { + const selectedRowId = selectedRow?.id; + const updatedRow = profiles.find((profile) => profile.id === selectedRowId); + if (!updatedRow) return; + setSelectedRow(updatedRow); + } + } + }; + + let interval: any; + useEffect(() => { + if (interval) clearInterval(interval); + + if (selectedRow?.approved && realmCreatingStatuses.includes(selectedRow?.status || '')) { + interval = setInterval(() => { + fetchRealms(); + }, 15000); + } + + return () => clearInterval(interval); + }, [selectedRow]); + + return ( + +

    Custom Realm Dashboard

    + + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + ))} + + ))} + + + {table.getRowModel().rows.map((row) => ( + setSelectedRow(row.original)} + className={row.getValue('id') === selectedRow?.id ? 'selected' : ''} + data-testid={`custom-realm-row-${row.getValue('id')}`} + > + {row.getVisibleCells().map((cell) => ( + + ))} + + ))} + +
    + {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())} +
    {flexRender(cell.column.columnDef.cell, cell.getContext())}
    + {selectedRow && ( + + )} +
    + ); +} + +export default withBottomAlert(CustomRealmDashboard); + +interface ExtendedForm extends CustomRealmFormData { + createdAt: object; + updatedAt: object; +} + +/**Fetch realm data with first page load */ +export const getServerSideProps = async (context: GetServerSidePropsContext) => { + const session = await getServerSession(context.req, context.res, authOptions); + if (!session) + return { + props: { defaultRealmRequests: [] }, + }; + + const username = session?.user?.idir_username || ''; + const isAdmin = checkAdminRole(session?.user); + + try { + const realms = await getAllRealms(username, isAdmin); + // Strip non-serializable dates + const formattedRealms = realms.map((realm: ExtendedForm) => { + const { createdAt, updatedAt, ...rest } = realm; + return rest; + }); + + return { + props: { + defaultRealmRequests: formattedRealms, + }, + }; + } catch (err) { + console.log(err); + return { + props: { + defaltRealmRequests: [], + }, + }; + } +}; diff --git a/app/pages/custom-realm-form.tsx b/app/pages/custom-realm-form.tsx new file mode 100644 index 0000000..3c49ea1 --- /dev/null +++ b/app/pages/custom-realm-form.tsx @@ -0,0 +1,457 @@ +import React, { useState, useRef, ChangeEvent, useContext } from 'react'; +import Head from 'next/head'; +import { Grid as SpinnerGrid } from 'react-loader-spinner'; +import styled from 'styled-components'; +import Button from '@button-inc/bcgov-theme/Button'; +import { submitRealmRequest } from 'services/realm'; +import { CustomRealmFormData } from 'types/realm-profile'; +import { withBottomAlert, BottomAlert } from 'layout/BottomAlert'; +import { useSession, signIn } from 'next-auth/react'; +import { useRouter } from 'next/router'; +import { ModalContext } from 'context/modal'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faInfoCircle } from '@fortawesome/free-solid-svg-icons'; +import { createRealmSchema } from 'validators/create-realm'; +import { ValidationError } from 'yup'; +import cloneDeep from 'lodash.clonedeep'; + +const SForm = styled.form` + display: grid; + grid-template-columns: 1fr 1fr; + column-gap: 2em; + row-gap: 1em; + padding: 2em; + + .error-message { + color: red; + font-size: 0.8em; + padding: 0; + margin: 0; + } + + .first-col { + grid-column: 1; + } + + .second-col { + grid-column: 2; + } + + .span-cols { + grid-column: 1 / 3; + } + + label, + legend { + &.required:after { + content: ' *'; + } + + &.with-info svg { + margin: 0 0.3em; + } + } + fieldset { + border: 0; + legend { + font-size: 1em; + margin-bottom: 0; + } + } + + .input-wrapper { + display: flex; + flex-direction: column; + } + + .checkbox-wrapper, + .radio-wrapper { + input { + margin-right: 0.5em; + } + } + + .checkbox-wrapper.with-textarea { + display: flex; + flex-direction: row; + align-items: flex-start; + justify-content: space-between; + + input { + margin-top: 0.4em; + } + + .textarea-container { + margin-left: 1em; + flex: 1; + textarea { + width: 100%; + } + + .help-text { + color: grey; + text-align: right; + margin: 0; + } + } + } + + .grid { + display: grid; + grid-template-columns: 1fr 1fr; + column-gap: 2em; + row-gap: 1em; + } +`; + +const ButtonContainer = styled.div` + padding: 0 2em; + width: 100%; + display: flex; + justify-content: space-between; + button { + width: 8em; + } +`; + +const defaultData: CustomRealmFormData = { + status: 'pending', + realm: '', + purpose: '', + primaryEndUsers: [], + environments: [], + productOwnerEmail: '', + productOwnerIdirUserId: '', + technicalContactEmail: '', + technicalContactIdirUserId: '', + secondTechnicalContactIdirUserId: '', + secondTechnicalContactEmail: '', +}; + +const validateForm = (data: CustomRealmFormData) => { + try { + createRealmSchema.validateSync(data, { abortEarly: false, stripUnknown: true }); + return { valid: true, errors: null }; + } catch (e) { + const err = e as ValidationError; + const formErrors: { [key in keyof CustomRealmFormData]?: boolean } = {}; + err.errors.forEach((error) => { + // Yup error strings begin with object key + const fieldName = error.split(' ')[0] as keyof CustomRealmFormData; + formErrors[fieldName] = true; + }); + return { valid: false, errors: formErrors }; + } +}; + +interface Props { + alert: BottomAlert; +} + +function RealmForm({ alert }: Props) { + const formRef = useRef(null); + const [formData, setFormData] = useState(defaultData); + const [formErrors, setFormErrors] = useState<{ [key in keyof CustomRealmFormData]?: boolean }>({}); + const [submittingForm, setSubmittingForm] = useState(false); + const { setModalConfig } = useContext(ModalContext); + const router = useRouter(); + + const [otherPrimaryEndUsersSelected, setOtherPrimaryEndUsersSelected] = useState(false); + const [otherPrimaryEndUserDetails, setOtherPrimaryEndUserDetails] = useState(''); + + // Redirect if not authenticated/loading + const { status } = useSession(); + if (!['loading', 'authenticated'].includes(status)) { + signIn('keycloak', { + callbackUrl: '/custom-realm-form', + }); + return null; + } + + const requiredMessage = 'Fill in the required fields.'; + const requiredEmailMessage = 'Fill this in with a proper email.'; + const twoCharactersRequiredMessage = 'This field must be at least two characters.'; + + const handleSubmit = async () => { + const submission = cloneDeep(formData); + const { valid, errors } = validateForm(submission); + if (!valid) { + setFormErrors(errors as any); + return; + } + setSubmittingForm(true); + + // Add other primary users if present + if (otherPrimaryEndUsersSelected) { + submission.primaryEndUsers.push(otherPrimaryEndUserDetails); + } + const [response, err] = await submitRealmRequest(submission); + if (err) { + const content = err?.response?.data?.error || 'Network request failure. Please try again.'; + alert.show({ + variant: 'danger', + fadeOut: 10000, + closable: true, + content, + }); + } else { + router.push('/').then(() => { + setModalConfig({ + title: 'Custom Realm request submitted', + body: `We have received your request for a Custom Realm (ID ${response.id}). Please be assured that someone from our team will look into your request and will reach out soon.`, + show: true, + }); + }); + } + setSubmittingForm(false); + }; + + // Change handlers + const handleFormInputChange = (e: ChangeEvent) => { + setFormErrors({ ...formErrors, [e.target.name]: false }); + setFormData({ ...formData, [e.target.name]: e.target.value }); + }; + + const handleFormCheckboxGroupChange = ( + e: ChangeEvent, + groupName: 'environments' | 'primaryEndUsers', + ) => { + setFormErrors({ ...formErrors, [groupName]: false }); + let newData = { ...formData }; + if (e.target.checked && !formData[groupName].includes(e.target.value)) { + newData = { ...formData, [groupName]: [...formData[groupName], e.target.name] }; + } else { + newData = { ...formData, [groupName]: formData[groupName].filter((val) => val !== e.target.name) }; + } + setFormData(newData); + }; + + return ( + <> + + Custom Realm + + +

    Request a Custom Realm

    + +
    + + + {formErrors.realm &&

    {twoCharactersRequiredMessage}

    } +
    + +
    + + + {formErrors.purpose &&

    {twoCharactersRequiredMessage}

    } +
    + +
    + + 3. Who are the primary end users of your project/application? (select all that apply) + + {formErrors.primaryEndUsers &&

    You must select one or more.

    } +
    +
    + handleFormCheckboxGroupChange(e, 'primaryEndUsers')} + checked={formData.primaryEndUsers.includes('livingInBC')} + /> + +
    + +
    + handleFormCheckboxGroupChange(e, 'primaryEndUsers')} + checked={formData.primaryEndUsers.includes('businessInBC')} + /> + +
    + +
    + handleFormCheckboxGroupChange(e, 'primaryEndUsers')} + checked={formData.primaryEndUsers.includes('govEmployees')} + /> + +
    + +
    + { + setOtherPrimaryEndUsersSelected(!otherPrimaryEndUsersSelected); + if (!e.target.checked) setOtherPrimaryEndUserDetails(''); + }} + checked={otherPrimaryEndUsersSelected} + /> + +
    +