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

feat: store prefill in frontend for spcp/myinfo forms, enable prefill for SGID forms #3920

Merged
merged 7 commits into from
Jul 1, 2022
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/app/modules/form/public-form/public-form.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -395,7 +395,7 @@ export const handleGetPublicForm: ControllerHandler<
* NOTE: This is exported only for testing
* Generates redirect URL to Official SingPass/CorpPass log in page
* @param isPersistentLogin whether the client wants to have their login information stored
* @param encodedQuery base64 encoded querystring (usually contains prefilled form information)
* @param encodedQuery base64 encoded queryId for frontend to retrieve stored query params (usually contains prefilled form information)
* @returns 200 with the redirect url when the user authenticates successfully
* @returns 400 when there is an error on the authType of the form
* @returns 400 when the eServiceId of the form does not exist
Expand Down Expand Up @@ -453,6 +453,7 @@ export const _handleFormAuthRedirect: ControllerHandler<
return SgidService.createRedirectUrl(
formId,
Boolean(isPersistentLogin),
encodedQuery,
)
})
default:
Expand Down
1 change: 1 addition & 0 deletions src/app/modules/sgid/__tests__/sgid.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ describe('sgid.service', () => {
it('should parse state', () => {
const state = SgidService.parseState(MOCK_STATE)
expect(state._unsafeUnwrap()).toStrictEqual({
decodedQuery: '',
formId: MOCK_DESTINATION,
rememberMe: MOCK_REMEMBER_ME,
})
Expand Down
10 changes: 6 additions & 4 deletions src/app/modules/sgid/sgid.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,9 @@ export const handleLogin: ControllerHandler<
return res.sendStatus(StatusCodes.BAD_REQUEST)
}

const { formId, rememberMe } = parsedState.value
const { formId, rememberMe, decodedQuery } = parsedState.value
const target = decodedQuery ? `/${formId}${decodedQuery}` : `/${formId}`

const formResult = await FormService.retrieveFullFormById(formId)
if (formResult.isErr()) {
logger.error({
Expand All @@ -54,7 +56,7 @@ export const handleLogin: ControllerHandler<
},
})
res.cookie('isLoginError', true)
return res.redirect(`/${formId}`)
return res.redirect(target)
}

const jwtResult = await SgidService.retrieveAccessToken(code)
Expand All @@ -68,7 +70,7 @@ export const handleLogin: ControllerHandler<
error: jwtResult.error,
})
res.cookie('isLoginError', true)
return res.redirect(`/${formId}`)
return res.redirect(target)
}

const { maxAge, jwt } = jwtResult.value
Expand All @@ -79,5 +81,5 @@ export const handleLogin: ControllerHandler<
secure: !config.isDev,
...SgidService.getCookieSettings(),
})
return res.redirect(`/${formId}`)
return res.redirect(target)
}
45 changes: 36 additions & 9 deletions src/app/modules/sgid/sgid.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,13 +60,17 @@ export class SgidServiceClass {
* Create a URL to sgID which is used to redirect the user for authentication
* @param formId - the form id to redirect to after authentication
* @param rememberMe - whether we create a JWT that remembers the user
* @param encodedQuery base64 encoded queryId for frontend to retrieve stored query params (usually contains prefilled form information)
* for an extended period of time
*/
createRedirectUrl(
formId: string,
rememberMe: boolean,
encodedQuery?: string,
): Result<string, SgidCreateRedirectUrlError> {
const state = `${formId},${rememberMe}`
const state = encodedQuery
? `${formId},${rememberMe},${encodedQuery}`
: `${formId},${rememberMe}`
const logMeta = {
action: 'createRedirectUrl',
state,
Expand All @@ -88,18 +92,41 @@ export class SgidServiceClass {
* Parses the string serialization containing the form id and if the
* user should be remembered, both needed when redirecting the user back to
* the form post-authentication
* @param state - a comma-separated string of the form id and a boolean flag
* indicating if the user should be remembered
* @returns {Result<{ formId: string; rememberMe: boolean }, SgidInvalidStateError>}
* the form id and whether the user should be remembered
* @param state - a comma-separated string of the form id, a boolean flag
* indicating if the user should be remembered, and an optional encodedQuery
* @returns {Result<{ formId: string; rememberMe: boolean; decodedQuery?: string }, SgidInvalidStateError>}
*/
parseState(
state: string,
): Result<{ formId: string; rememberMe: boolean }, SgidInvalidStateError> {
const [formId, rememberMeStr] = state.split(',')
const rememberMe = rememberMeStr === 'true'
): Result<
{ formId: string; rememberMe: boolean; decodedQuery: string },
SgidInvalidStateError
> {
const payloads = state.split(',')
const formId = payloads[0]
const rememberMe = payloads[1] === 'true'

const encodedQuery = payloads.length === 3 ? payloads[2] : ''
let decodedQuery = ''

try {
decodedQuery = encodedQuery
? `?${Buffer.from(encodedQuery, 'base64').toString('utf8')}`
: ''
} catch (e) {
logger.error({
message: 'Unable to decode encodedQuery',
meta: {
action: 'parseOOBParams',
encodedQuery,
},
error: e,
})
return err(new SgidInvalidStateError())
}

return formId
? ok({ formId, rememberMe })
? ok({ formId, rememberMe, decodedQuery })
: err(new SgidInvalidStateError())
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,20 +27,55 @@ function SubmitFormController(
// The form attribute of the FormData object contains the form fields, logic etc
vm.myform = FormData.form

const isSpcpSgidForm = !!['SP', 'CP', 'SGID'].includes(vm.myform.authType)
const isMyInfoForm = !!(
!FormData.isTemplate && vm.myform.authType === 'MyInfo'
tshuli marked this conversation as resolved.
Show resolved Hide resolved
)
const isUserLoggedIn = !!(
FormData.spcpSession && FormData.spcpSession.userName
)

// If it is an authenticated form, read the storedQuery from local storage and append to query params
// Note that regardless of whether user is logged in, we should replace the queryId with the
tshuli marked this conversation as resolved.
Show resolved Hide resolved
// stored query params
if (isSpcpSgidForm || isMyInfoForm) {
const location = $window.location.toString().split('?')
tshuli marked this conversation as resolved.
Show resolved Hide resolved
if (location.length > 1) {
const queryParams = new URLSearchParams(location[1])
const queryId = queryParams.get('queryId')

let storedQuery

try {
// If storedQuery is not valid JSON, JSON.parse throws a SyntaxError
// In try-catch block as this should not prevent rest of form from being loaded
storedQuery = JSON.parse($window.sessionStorage.getItem('storedQuery'))
tshuli marked this conversation as resolved.
Show resolved Hide resolved
} catch (e) {
console.error('Unable to parse storedQuery, not valid JSON string')
}

if (
queryId &&
storedQuery &&
storedQuery._id === queryId &&
storedQuery.queryString
) {
$window.location.href = `${location[0]}?${storedQuery.queryString}` // Replace the queryId with stored queryString
tshuli marked this conversation as resolved.
Show resolved Hide resolved
$window.sessionStorage.removeItem('storedQuery') // Delete after reading the stored queryString, as only needed once
}
}
}

// For SP / CP forms, also include the spcpSession details
// This allows the log out button to be correctly populated with the UID
// Also provides time to cookie expiry so that client can refresh page
if (
['SP', 'CP', 'SGID'].includes(vm.myform.authType) &&
FormData.spcpSession &&
FormData.spcpSession.userName
) {
if (isSpcpSgidForm && isUserLoggedIn) {
SpcpSession.setUser(FormData.spcpSession)
}

// Set MyInfo login status
if (!FormData.isTemplate && vm.myform.authType === 'MyInfo') {
if (FormData.spcpSession && FormData.spcpSession.userName) {
if (isMyInfoForm) {
if (isUserLoggedIn) {
SpcpSession.setUserName(FormData.spcpSession.userName)
} else {
SpcpSession.clearUserName()
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
'use strict'
const cloneDeep = require('lodash/cloneDeep')
const get = require('lodash/get')
const { ObjectId } = require('bson')
tshuli marked this conversation as resolved.
Show resolved Hide resolved

const FieldVerificationService = require('../../../../services/FieldVerificationService')
const PublicFormAuthService = require('../../../../services/PublicFormAuthService')
Expand Down Expand Up @@ -101,7 +102,25 @@ function submitFormDirective(
if (isPersistentLogin) GTag.persistentLoginUse(scope.form)

const query = $location.url().split('?')
const encodedQuery = query.length > 1 ? btoa(query[1]) : undefined
const queryString = query.length > 1 ? query[1] : undefined
tshuli marked this conversation as resolved.
Show resolved Hide resolved
const queryId = queryString ? new ObjectId() : undefined
const encodedQuery = queryId ? btoa(`queryId=${queryId}`) : undefined
const queryObject = {
_id: queryId,
queryString,
}

if (queryString) {
// Defensive - try catch block in case the storage is full
// See https://developer.mozilla.org/en-US/docs/Web/API/Storage/setItem
try {
sessionStorage.setItem('storedQuery', JSON.stringify(queryObject))
} catch (e) {
console.error('Failed to store query string')
// Login can proceed, since after login, user can still prefill form by accessing
// url with query params
}
}

return $q
.when(
Expand Down