diff --git a/components/case/summaryTypes/pinswg_compulsorypurchaseordersid.js b/components/case/summaryTypes/pinswg_compulsorypurchaseordersid.js index a672a11f..3f9ba8d1 100644 --- a/components/case/summaryTypes/pinswg_compulsorypurchaseordersid.js +++ b/components/case/summaryTypes/pinswg_compulsorypurchaseordersid.js @@ -2,6 +2,7 @@ import _ from "lodash"; import useTranslation from "next-translate/useTranslation"; import { useRouter } from "next/router"; import transLookup from "../../../data/lookuptranslations.json"; +import resolveCrmDisplayValue from "../../../lib/i18n/crmDisplay/resolveCrmDisplayValue"; import SummaryCard from "../summary/components/SummaryCard"; import SummaryRow from "../summary/components/SummaryRow"; import { hasOwn } from "../../utils"; diff --git a/components/case/summaryTypes/pinswg_transportandworkactid.js b/components/case/summaryTypes/pinswg_transportandworkactid.js index 50c05034..3d7f7434 100644 --- a/components/case/summaryTypes/pinswg_transportandworkactid.js +++ b/components/case/summaryTypes/pinswg_transportandworkactid.js @@ -5,6 +5,7 @@ import { useRouter } from "next/router"; import transLookup from "../../../data/lookuptranslations.json"; import SummaryCard from "../summary/components/SummaryCard"; import SummaryRow from "../summary/components/SummaryRow"; +import resolveCrmDisplayValue from "../../../lib/i18n/crmDisplay/resolveCrmDisplayValue"; import { hasOwn } from "../../utils"; const Pinswg_transportandworkactid = (props) => { diff --git a/pages/api/auth/[...nextauth].js b/pages/api/auth/[...nextauth].js index 56b82a7b..96c5f8b2 100644 --- a/pages/api/auth/[...nextauth].js +++ b/pages/api/auth/[...nextauth].js @@ -19,6 +19,40 @@ import { getPortalLogin } from "../../../actions/services/accountService"; const prisma = new PrismaClient(); +const createVerificationTokenCompletion = () => { + let complete; + + const promise = new Promise((resolve) => { + complete = resolve; + }); + + return { promise, complete }; +}; + +const createVerificationTokenSynchronizationAdapter = ( + verificationTokenCompletion +) => { + const adapter = PrismaAdapter(prisma); + + return { + ...adapter, + + async createVerificationToken(data) { + try { + const result = await adapter.createVerificationToken(data); + + verificationTokenCompletion.complete({ result }); + + return result; + } catch (error) { + verificationTokenCompletion.complete({ error }); + + throw error; + } + } + }; +}; + const WELSH_LANGUAGE_CODE = 846040000; const authDiagnostic = (event, metadata = {}) => { @@ -167,6 +201,7 @@ const resolveRequestLocale = (req) => { const authOptions = (req, res) => { const requestLocale = resolveRequestLocale(req); + const verificationTokenCompletion = createVerificationTokenCompletion(); return { providers: [ @@ -186,6 +221,13 @@ const authOptions = (req, res) => { effectiveLocale }); + const tokenCreation = + await verificationTokenCompletion.promise; + + if (tokenCreation.error) { + throw tokenCreation.error; + } + return res .writeHead(200, { "Content-Type": "application/json" }) .json({ url: formURL }); @@ -240,7 +282,10 @@ const authOptions = (req, res) => { } }) ], - adapter: PrismaAdapter(prisma), + + adapter: createVerificationTokenSynchronizationAdapter( + verificationTokenCompletion + ), secret: process.env.NEXTAUTH_SECRET, session: { strategy: "database", diff --git a/pages/myportal/index.js b/pages/myportal/index.js index be202731..dfc737c9 100644 --- a/pages/myportal/index.js +++ b/pages/myportal/index.js @@ -317,11 +317,11 @@ export const getServerSideProps = wrapper.getServerSideProps( const limitRequests = pLimit(5); const arr = []; - const list = - detailsType === "mySubmittedReps" - ? resultsObj - : resultsObj.value; - + const list = resultsObj.value; + // detailsType === "mySubmittedReps" + // ? resultsObj + // : resultsObj.value; + // console.log(list); const tasks = list.map((item) => limitRequests(async () => { try { @@ -374,18 +374,37 @@ export const getServerSideProps = wrapper.getServerSideProps( return arr; }; + const DASHBOARD_LIMIT = 3; + + const limitResults = (data) => ({ + ...data, + value: (data?.value ?? []).slice(0, DASHBOARD_LIMIT) + }); + const [ myCasesDetails, watchedCasesDetails, mySubmittedRepsDetails, myRepresentationsDetails ] = await Promise.all([ - getDetails(myCases, "myCases"), - getDetails(filteredWatchedCases, "myWatchedCases"), - getDetails(mySubmittedReps, "mySubmittedReps"), + getDetails(limitResults(myCases), "myCases"), + getDetails(limitResults(filteredWatchedCases), "myWatchedCases"), + getDetails(limitResults(mySubmittedReps), "mySubmittedReps"), getDetails(myRepresentations, "myRepresentations") ]); + // const [ + // myCasesDetails, + // watchedCasesDetails, + // mySubmittedRepsDetails, + // myRepresentationsDetails + // ] = await Promise.all([ + // getDetails(limitResults(myCases), "myCases"), + // getDetails(limitResults(filteredWatchedCases), "myWatchedCases"), + // getDetails(limitResults(mySubmittedReps), "mySubmittedReps"), + // getDetails(limitResults(myRepresentations), "myRepresentations") + // ]); + store.dispatch(setAccountDetails(accountDetails)); store.dispatch(setLoggedInUserId(loggedInUser)); store.dispatch(setMyCases(myCases)); diff --git a/pages/myportal/viewall.js b/pages/myportal/viewall.js index 69c43f67..46aa4cbc 100644 --- a/pages/myportal/viewall.js +++ b/pages/myportal/viewall.js @@ -11,7 +11,63 @@ import ViewAll from "../../components/myportal/viewall"; import NoSessionWarning from "../../components/nosession"; import TimeOut from "../../components/timeout"; import ServiceBanner from "../../components/myportal/servicebanner"; +import { getSession } from "next-auth/react"; +import pLimit from "p-limit"; +import { + getPersonalAccount, + getPortalLogin +} from "../../actions/services/accountService"; + +import { getPortalModuleDetails } from "../../actions/services/caseService"; + +import { + getAwaitingSubmissionFromBlob, + getRepsFromBlob +} from "../../actions/services/documentService"; + +import { + getMyCases, + getMyLPACases, + getWatchedCases +} from "../../actions/services/portalService"; + +import { getBasicSearch } from "../../actions/services/searchService"; + +import { splitWatchedCasesBySubmissionState } from "../../lib/domain/dashboard-policy"; + +import { getFormCollectionByID } from "../../components/utils"; + +import { + setAccountDetails, + setContainerID, + setLoggedInUserId +} from "../../store/accountDetails/action"; + +import { + setAwaitingSubmissionDetails, + setAwaitingSubmissionFromBlob +} from "../../store/awaitingSubmission/action"; + +import { setCurrentView } from "../../store/currentView/action"; + +import { setMyCases, setMyCasesDetails } from "../../store/myCases/action"; + +import { + setMyRepresentations, + setMyRepresentationsDetails, + setMySubmittedReps, + setMySubmittedRepsDetails +} from "../../store/myRepresentations/action"; + +import { + setWatchedCases, + setWatchedCasesDetails +} from "../../store/watchedCases/action"; + +import { setSearchResults } from "../../store/searchOutput/action"; +import { setSearch } from "../../store/search/action"; +import { wrapper } from "../../store/store"; const Home = (props) => { const { footerLinks, pages } = props; let { t, lang } = useTranslation(); @@ -39,7 +95,7 @@ const Home = (props) => { ), "myRepresentations": t("common:myrepresentations-viewall-title"), "watchedCases": t("common:watchedcases-viewall-title"), - "mySubmittedReps": t("common:mysubmittedreps-viewall-title"), + "mySubmittedReps": t("common:mysubmittedreps-viewall-title") }; function whichTitle(key) { @@ -83,14 +139,275 @@ const Home = (props) => { ); }; -// export const getServerSideProps = wrapper.getServerSideProps( -// (store) => -// async ({ req, res }) => { -// const searchResultsObj = (await getBasicSearch("View All")) || null; -// store.dispatch(setSearchResults(searchResultsObj)); -// store.dispatch(setSearch("View All")); -// } -// ); +export const getServerSideProps = wrapper.getServerSideProps( + (store) => async (ctx) => { + const { query } = ctx; + + // The View All component uses router.query.key. + const viewKey = Array.isArray(query.key) ? query.key[0] : query.key; + console.log("[ViewAll SSR] Query:", ctx.query); + console.log("[ViewAll SSR] Selected viewKey:", viewKey); + const validViewKeys = [ + "myCases", + "watchedCases", + "mySubmittedReps", + "myRepresentations", + "awaitingSubmissionDetails" + ]; + + if (!validViewKeys.includes(viewKey)) { + return { + redirect: { + destination: "/myportal", + permanent: false + } + }; + } + + // Authenticate before retrieving portal data. + const thisSession = await getSession(ctx); + + if (!thisSession?.user?.email) { + return { + redirect: { + destination: "/auth/signin", + permanent: false + } + }; + } + + const portalLogin = await getPortalLogin(thisSession.user.email); + + const loggedInUser = portalLogin?.value?.[0]?.contactid; + + if (!loggedInUser) { + return { + redirect: { + destination: "/auth/signin", + permanent: false + } + }; + } + + const accountDetails = await getPersonalAccount(loggedInUser); + + if (!accountDetails || accountDetails.errorCode) { + return { + redirect: { + destination: "/myportal/error", + permanent: false + } + }; + } + + // Preserve the LPA logic from MyPortal index. + const isLPA = accountDetails.pinswg_typeofinvolvement === 846040012; + + const lpaId = + accountDetails[ + "pinswg_contact_associatedlpa@OData.Community.Display.V1.FormattedValue" + ]; + + // Full-list detail enrichment. + // No dashboard limit is applied here. + const getDetails = async (resultsObj, detailsType) => { + const limitRequests = pLimit(5); + + const list = Array.isArray(resultsObj) + ? resultsObj + : (resultsObj?.value ?? []); + + const tasks = list.map((item) => + limitRequests(async () => { + try { + let caseID = ""; + + switch (detailsType) { + case "myCases": + caseID = item.ticketnumber; + break; + + case "myWatchedCases": + case "mySubmittedReps": + caseID = + item[ + "_pinswg_watchedcase_value@OData.Community.Display.V1.FormattedValue" + ]; + break; + + case "awaitingSubmission": + caseID = item.ticketnumber; + break; + + case "myRepresentations": + caseID = item.casereference; + break; + + default: + caseID = item.pinswg_title; + } + + if (!item.pinswg_appealcasetype || !caseID) { + return null; + } + + const formCollection = getFormCollectionByID( + item.pinswg_appealcasetype + ); + + if (!formCollection?.LogicalCollectionName) { + console.error( + "Missing collection metadata for appeal type:", + item.pinswg_appealcasetype + ); + return null; + } + + return ( + (await getPortalModuleDetails( + formCollection.LogicalCollectionName, + caseID + )) || {} + ); + } catch (err) { + console.error("Error fetching View All details:", err); + return null; + } + }) + ); + + const details = await Promise.all(tasks); + + // Retain the original result ordering. + return details.filter(Boolean); + }; + + // Shared account state. + store.dispatch(setAccountDetails(accountDetails)); + store.dispatch(setLoggedInUserId(loggedInUser)); + store.dispatch(setContainerID(thisSession.user.id)); + + // Ensure the rendered View All category agrees + // with the category loaded by this request. + store.dispatch( + setCurrentView({ + viewKey, + viewName: viewKey + }) + ); + + switch (viewKey) { + case "myCases": { + const myCases = isLPA + ? await getMyLPACases(lpaId) + : await getMyCases(loggedInUser); + + const myCasesDetails = await getDetails(myCases, "myCases"); + + store.dispatch(setMyCases(myCases)); + store.dispatch(setMyCasesDetails(myCasesDetails)); + + break; + } + + case "watchedCases": { + const watchedCases = await getWatchedCases(loggedInUser); + + const { watchedCases: filteredWatchedCases } = + splitWatchedCasesBySubmissionState( + watchedCases.value ?? [] + ); + + const watchedCasesDetails = await getDetails( + filteredWatchedCases, + "myWatchedCases" + ); + + store.dispatch(setWatchedCases(filteredWatchedCases)); + + store.dispatch(setWatchedCasesDetails(watchedCasesDetails)); + + break; + } + + case "mySubmittedReps": { + const watchedCases = await getWatchedCases(loggedInUser); + + const { submittedRepresentations: mySubmittedReps } = + splitWatchedCasesBySubmissionState( + watchedCases.value ?? [] + ); + + const mySubmittedRepsDetails = await getDetails( + mySubmittedReps, + "mySubmittedReps" + ); + + store.dispatch(setMySubmittedReps(mySubmittedReps)); + + store.dispatch( + setMySubmittedRepsDetails(mySubmittedRepsDetails) + ); + + break; + } + + case "myRepresentations": { + const myRepresentations = await getRepsFromBlob( + thisSession.user.id + ); + + // The existing View All component reads + // representation display data directly + // from myRepresentations.value. + store.dispatch(setMyRepresentations(myRepresentations)); + + // Preserve the existing Redux detail + // collection contract. + const myRepresentationsDetails = await getDetails( + myRepresentations, + "myRepresentations" + ); + + store.dispatch( + setMyRepresentationsDetails(myRepresentationsDetails) + ); + + break; + } + + case "awaitingSubmissionDetails": { + const awaitingSubmissionFromBlob = + await getAwaitingSubmissionFromBlob(thisSession.user.id); + + // Awaiting submission already has its + // own Blob-backed data structure. + // Preserve the dashboard's existing + // Redux dispatches. + store.dispatch( + setAwaitingSubmissionFromBlob(awaitingSubmissionFromBlob) + ); + + store.dispatch( + setAwaitingSubmissionDetails(awaitingSubmissionFromBlob) + ); + + break; + } + } + + // Preserve the original View All search state. + const searchResultsObj = (await getBasicSearch("View All")) || null; + + store.dispatch(setSearchResults(searchResultsObj)); + + store.dispatch(setSearch("View All")); + + return { + props: {} + }; + } +); const mapStateToProps = (state) => { return { @@ -105,7 +422,7 @@ const mapStateToProps = (state) => { myRepresentations: state.myRepresentations, awaitingSubmission: state.awaitingSubmission, currentView: state.currentView, - mySubmittedReps: state.myRepresentations.mySubmittedReps, + mySubmittedReps: state.myRepresentations.mySubmittedReps }; }; diff --git a/store/store.js b/store/store.js index 3876194c..35eec24d 100644 --- a/store/store.js +++ b/store/store.js @@ -31,7 +31,7 @@ const appReducer = combineReducers({ myCases, myRepresentations, watchedCases, - form: formReducer, + form: formReducer }); const combinedReducer = (state, action) => { @@ -53,28 +53,28 @@ const reducer = (state = {}, action) => { ...state, server: { ...state.server, - progress: action.payload, - }, + progress: action.payload + } }; case "CLIENT_ACTION": return { ...state, client: { ...state.client, - progress: action.payload, - }, + progress: action.payload + } }; case "GET_ITEMS": return { ...state, items: action.payload, - loading: false, + loading: false }; case "SET_ITEMS": return { ...state, items: action.payload, - loading: false, + loading: false }; default: return combinedReducer(state, action); @@ -84,7 +84,7 @@ const reducer = (state = {}, action) => { const migrations = { 1: (state) => { return { - ...state, + ...state }; }, 2: (state) => { @@ -93,12 +93,12 @@ const migrations = { const newStateObj = { ...state, "currentView": {}, - "searchResultsObj": {}, + "searchResultsObj": {} }; console.info("v2 schema migration:" + JSON.stringify(newStateObj)); return newStateObj; - }, + } }; // BINDING MIDDLEWARE @@ -119,7 +119,7 @@ const makeStore = ({ isServer }) => { const { persistStore, persistReducer, - createMigrate, + createMigrate } = require("redux-persist"); const storage = require("redux-persist/lib/storage").default; @@ -132,15 +132,15 @@ const makeStore = ({ isServer }) => { "LPAData", "accountDetails", "awaitingSubmission", - "myCases", + // "myCases", "myRepresentations", "watchedCases", - "form", + "form" ], version: process.env.BUILD_ID_ENV, storage: storage, stateReconciler: autoMergeLevel2, // if needed, use a safer storage - migrate: createMigrate(migrations, { debug: true }), + migrate: createMigrate(migrations, { debug: true }) }; const persistedReducer = persistReducer(persistConfig, reducer); // Create a new reducer with our existing reducer @@ -157,7 +157,7 @@ const makeStore = ({ isServer }) => { }; export const setClientState = (clientState) => ({ type: SET_CLIENT_STATE, - payload: clientState, + payload: clientState }); // Export the wrapper & wrap the pages/_app.js with this wrapper only export const wrapper = createWrapper(makeStore); diff --git a/tests/phase22/auth-redirect-safety.test.cjs b/tests/phase22/auth-redirect-safety.test.cjs index 9cbf80d8..95c92c4f 100644 --- a/tests/phase22/auth-redirect-safety.test.cjs +++ b/tests/phase22/auth-redirect-safety.test.cjs @@ -6,7 +6,7 @@ const vm = require("vm"); const tests = []; const test = (name, fn) => tests.push({ name, fn }); -const loadAuthInternals = () => { +const loadAuthInternals = ({ adapterFactory = () => ({}) } = {}) => { const filePath = path.join( __dirname, "..", @@ -49,12 +49,14 @@ const loadAuthInternals = () => { NEXTAUTH_SECRET: "test-secret" } }, - PrismaAdapter: () => ({}), + PrismaAdapter: () => adapterFactory(), PrismaClient: function PrismaClient() { return {}; }, NextAuth: () => ({}), EmailProvider: () => ({}), + getPortalLogin: async () => ({ value: [] }), + redactSensitive: (value) => value, consoleLogger: () => {}, console: { log: () => {}, @@ -68,6 +70,42 @@ const loadAuthInternals = () => { return context.module.exports; }; +const deferred = () => { + let resolve; + let reject; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + + return { promise, resolve, reject }; +}; + +const createResponse = () => { + const calls = []; + const response = { + writeHead(status, headers) { + calls.push({ type: "writeHead", status, headers }); + return response; + }, + json(body) { + calls.push({ type: "json", body }); + return response; + } + }; + + return { response, calls }; +}; + +const createEmailApiRequest = () => ({ + query: { locale: "en" }, + body: {}, + cookies: {} +}); + +const verificationUrl = (token) => + `https://english.example/api/auth/callback/emailAPI?callbackUrl=https%3A%2F%2Fenglish.example&token=${token}&email=user%40example.com`; + test("auth/resolveRequestLocale prefers query then body then cookie then default", async () => { const mod = loadAuthInternals(); @@ -144,6 +182,130 @@ test("auth/redirect callback rewrites external URL to locale-safe base origin", assert.strictEqual(parsed.searchParams.get("token"), "abc"); }); +test("auth/emailAPI waits for successful token persistence before returning its URL", async () => { + const persistence = deferred(); + const mod = loadAuthInternals({ + adapterFactory: () => ({ + createVerificationToken: () => persistence.promise + }) + }); + const { response, calls } = createResponse(); + const options = mod.authOptions(createEmailApiRequest(), response); + const provider = options.providers[0]; + + const sendPromise = provider.sendVerificationRequest({ + identifier: "user@example.com", + url: verificationUrl("token-one") + }); + const createPromise = options.adapter.createVerificationToken({ + identifier: "user@example.com", + token: "stored-token-one", + expires: new Date(Date.now() + 60_000) + }); + + await Promise.resolve(); + await Promise.resolve(); + assert.deepStrictEqual(calls, []); + + persistence.resolve({ + identifier: "user@example.com", + token: "stored-token-one", + expires: new Date(Date.now() + 60_000) + }); + + await Promise.all([sendPromise, createPromise]); + + assert.strictEqual(calls[0].type, "writeHead"); + assert.strictEqual(calls[0].status, 200); + assert.strictEqual(calls[1].type, "json"); + assert.strictEqual(calls[1].body.url, verificationUrl("token-one")); +}); + +test("auth/emailAPI does not return a verification URL when persistence fails", async () => { + const persistence = deferred(); + const persistenceError = new Error("persistence failed"); + const mod = loadAuthInternals({ + adapterFactory: () => ({ + createVerificationToken: () => persistence.promise + }) + }); + const { response, calls } = createResponse(); + const options = mod.authOptions(createEmailApiRequest(), response); + + const sendPromise = options.providers[0].sendVerificationRequest({ + identifier: "user@example.com", + url: verificationUrl("token-two") + }); + const createPromise = options.adapter.createVerificationToken({ + identifier: "user@example.com", + token: "stored-token-two", + expires: new Date(Date.now() + 60_000) + }); + + persistence.reject(persistenceError); + + const [sendResult, createResult] = await Promise.allSettled([ + sendPromise, + createPromise + ]); + + assert.strictEqual(sendResult.status, "rejected"); + assert.strictEqual(sendResult.reason, persistenceError); + assert.strictEqual(createResult.status, "rejected"); + assert.strictEqual(createResult.reason, persistenceError); + assert.deepStrictEqual(calls, []); +}); + +test("auth/emailAPI keeps concurrent sign-in persistence signals request-scoped", async () => { + const firstPersistence = deferred(); + const secondPersistence = deferred(); + const adapters = [ + { createVerificationToken: () => firstPersistence.promise }, + { createVerificationToken: () => secondPersistence.promise } + ]; + const mod = loadAuthInternals({ + adapterFactory: () => adapters.shift() + }); + const firstResponse = createResponse(); + const secondResponse = createResponse(); + const firstOptions = mod.authOptions( + createEmailApiRequest(), + firstResponse.response + ); + const secondOptions = mod.authOptions( + createEmailApiRequest(), + secondResponse.response + ); + + const firstSend = firstOptions.providers[0].sendVerificationRequest({ + identifier: "user@example.com", + url: verificationUrl("first-token") + }); + const firstCreate = firstOptions.adapter.createVerificationToken({}); + const secondSend = secondOptions.providers[0].sendVerificationRequest({ + identifier: "user@example.com", + url: verificationUrl("second-token") + }); + const secondCreate = secondOptions.adapter.createVerificationToken({}); + + secondPersistence.resolve({ token: "stored-second-token" }); + await Promise.all([secondSend, secondCreate]); + + assert.deepStrictEqual(firstResponse.calls, []); + assert.strictEqual( + secondResponse.calls[1].body.url, + verificationUrl("second-token") + ); + + firstPersistence.resolve({ token: "stored-first-token" }); + await Promise.all([firstSend, firstCreate]); + + assert.strictEqual( + firstResponse.calls[1].body.url, + verificationUrl("first-token") + ); +}); + const run = async () => { let passed = 0;