Skip to content

Commit

Permalink
⭐ feature: Security questions (#2439)
Browse files Browse the repository at this point in the history
* initial setup

* Security Questions -> user profile page (#2416)

* Password Reset - security questions - initial setup (#2424)

* Add security questions check (#2429)

* Combine admin/formBuilder Headers and add Your Account dropdown (#2427)

* Revert radix dropdown-menu component to previous minor

* Add link to Profile

* Move form-builder header to globals

* move share dropdown next to your account

* replace AdminNav with new globals/Header

* Update i18n string

* Set min width and fix alignment

* load form-builder strings

* move string to common

* classname order

* remove shadow

* Fix admin menu test (#2440)

* Fix e2e test

* fix test

* Your Account / Profile tests (#2441)

* Add tests around Your Account dropdown and User Profile

* Delete redundant test

* Security questions edit modal (#2443)

* Adds initial modal work

* Adds more modal work

* Adds localization strings

* Update components/admin/Profile/EditSecurityQuestionModal.tsx

Co-authored-by: Tim Arney <timarney@users.noreply.github.com>

* Fixes from PR comments

* Fixes a typo

* Updates a test to include updated color

---------

Co-authored-by: Tim Arney <timarney@users.noreply.github.com>

* feat: added backend layer for security questions feature (#2445)

* feat: added backend layer for security questions feature

* refactor: remove unused security questions cache lib

* refactor: removed useless get security questions for logged in user API path (#2455)

* Connect setup security questions to API  - Answer questions PT 1 (#2458)

* initial api connection setup

* fix dependancy

* update check for fetch call

* lint

* Setup security questions (#2460)

* Adds fields and localization strings

* Moves work from signup to auth

* Removing a test for now

* Removes redirect to debug outside this PR

* fix loop issue

* add url

* clean

---------

Co-authored-by: Tim Arney <tim@line37.com>

* Connect profile and questions modal (#2461)

* Render questions from session

* wire up modal to update questions

* Remove old wip

* Refresh page after update

* Trimp answers

* remove whitespace

* remove unused'

* feat: sanitize security answers (#2468)

* Add min / set width for login content (#2466)

* add min / set width for login content

* Connect setup security questions to api (#2471)

* Adds API call

* Adds basic api response error handling

* Adds question filtering (#2473)

* Adds security questions page cypress tests (#2479)

* Update answer check (#2480)

* Add seeds for SecurityAnswers (#2481)

* Update Security questions test (#2482)

* Adds unknown error to profile dialog plus loader (#2484)

* Adds unknown error to profile dialog plus loader

* Fix question output (#2486)

* Redirect to profile from setup-security-questions (#2483)

---------

Co-authored-by: Dave Samojlenko <dave.samojlenko@cds-snc.ca>

* Removes select in favor of customizing Dropdown (#2487)

* Update error handling (#2488)

Co-authored-by: Daine Trinidad <daine.trinidad@gmail.com>

* Toast message for security questions (#2494)


---------

Co-authored-by: Anik Brazeau <38330843+anikbrazeau@users.noreply.github.com>

* Alert Banner (#2495)


---------

Co-authored-by: Dave Samojlenko <dave.samojlenko@cds-snc.ca>

* userHasSecurityQuestions property implementation (#2497)

* Verify email before showing security questions (#2496)

* magic link setup

* add code to get token

* added new specific errors for password reset lib function

* log and return when someone request reset password link with an email address that does not exist

---------

Co-authored-by: Clément Janin <clement.janin@cds-snc.ca>

---------

Co-authored-by: Tim Arney <tim@line37.com>
Co-authored-by: Tim Arney <timarney@users.noreply.github.com>
Co-authored-by: Pete <107579368+thiessenp-cds@users.noreply.github.com>
Co-authored-by: Clément JANIN <clement.janin@cds-snc.ca>
Co-authored-by: Daine Trinidad <daine.trinidad@gmail.com>
Co-authored-by: Anik Brazeau <38330843+anikbrazeau@users.noreply.github.com>
Co-authored-by: Bryan Robitaille <bryan.robitaille@cds-snc.ca>
  • Loading branch information
8 people authored Aug 9, 2023
1 parent 4d75fa7 commit f6f3362
Show file tree
Hide file tree
Showing 74 changed files with 2,937 additions and 436 deletions.
5 changes: 2 additions & 3 deletions .eslint-tailwindcss.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ module.exports = {
{
files: ["*.{ts,tsx}"],
extends: ["plugin:tailwindcss/recommended"],

parser: "@typescript-eslint/parser",
parserOptions: {
project: ["./tsconfig.json", "./cypress/tsconfig.json"],
Expand All @@ -16,8 +16,7 @@ module.exports = {
],
settings: {
tailwindcss: {
whitelist: ["(gc\\-).*", "form-builder", "page-container", "visually-hidden"]
whitelist: ["(gc\\-).*", "form-builder", "page-container", "visually-hidden", "buttons", "required", "focus-group"]
}
}
};

5 changes: 3 additions & 2 deletions __tests__/api/acceptable-use.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,14 @@

import { createMocks } from "node-mocks-http";
import acceptableUse from "@pages/api/acceptableuse";
import { setAcceptableUse } from "@lib/acceptableUseCache";
import { setAcceptableUse } from "@lib/cache/acceptableUseCache";
import { getCsrfToken } from "next-auth/react";
import { getServerSession } from "next-auth/next";
import { Session } from "next-auth";

jest.mock("next-auth/next");
jest.mock("next-auth/react");
jest.mock("@lib/acceptableUseCache");
jest.mock("@lib/cache/acceptableUseCache");
const mockedSetAcceptableUse = jest.mocked(setAcceptableUse, { shallow: true });
const mockedGetCsrfToken = jest.mocked(getCsrfToken, { shallow: true });
//Needed in the typescript version of the test so types are inferred correctly
Expand All @@ -27,6 +27,7 @@ describe("Test acceptable use endpoint", () => {
name: "forms user",
privileges: [],
acceptableUse: false,
hasSecurityQuestions: false,
},
};

Expand Down
231 changes: 231 additions & 0 deletions components/admin/Profile/EditSecurityQuestionModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,231 @@
import React, { useRef, useState, useCallback } from "react";
import { Label } from "@components/forms";
import { Button } from "@components/globals";
import { useTranslation } from "next-i18next";
import { Dialog, useDialogRef } from "@components/form-builder/app/shared";
import { logMessage } from "@lib/logger";
import { Attention, AttentionTypes } from "@components/globals/Attention/Attention";
import { Question } from "pages/profile";
import axios from "axios";
import { getCsrfToken } from "next-auth/react";
import { useRouter } from "next/router";
import debounce from "lodash.debounce";

const updateSecurityQuestion = async (
oldQuestionId: string,
newQuestionId: string,
answer: string | undefined
) => {
const csrfToken = await getCsrfToken();

if (csrfToken) {
await axios.put(
"/api/account/security-questions",
{
oldQuestionId: oldQuestionId,
newQuestionId: newQuestionId,
newAnswer: answer,
},
{
headers: {
"Content-Type": "application/json",
"X-CSRF-Token": csrfToken,
},
}
);
}
};

export const EditSecurityQuestionModal = ({
questionNumber,
questionId,
questions,
handleClose,
}: {
questionNumber: number;
questionId: string;
questions: Question[];
handleClose: () => void;
}) => {
const { t, i18n } = useTranslation(["profile"]);
const dialog = useDialogRef();
const questionRef = useRef<HTMLSelectElement>(null);
const answerRef = useRef<HTMLInputElement>(null);
const originalQuestionId = questionId;
const router = useRouter();

const [isFormError, setIsFormError] = useState(false);
const [isFormWarning, setIsFormWarning] = useState(false);

const [isAnswerInputError, setIsAnswerInputError] = useState(false);
const isAnswerInputValid = (text: string | undefined): boolean => {
if (text && text.length >= 4) {
return true;
}
return false;
};

const langKey = i18n.language === "en" ? "questionEn" : "questionFr";

const _debouncedAnswerCheck = debounce(
useCallback(() => {
if (!isAnswerInputValid(answerRef.current?.value)) {
setIsFormWarning(false);
setIsAnswerInputError(true);
return;
}

setIsAnswerInputError(false);
setIsFormWarning(true);
}, []),
500
);

const reset = () => {
setIsFormError(false);
setIsFormWarning(false);
setIsAnswerInputError(false);
};

const handleSubmit = async () => {
try {
reset();

const questionId = questionRef.current?.value;
const questionAnswer = answerRef.current?.value;

if (!questionId) {
throw Error("Question Id required for security question API call");
}

if (!isAnswerInputValid(questionAnswer)) {
setIsAnswerInputError(true);
return;
}

await updateSecurityQuestion(originalQuestionId, questionId, questionAnswer);

dialog.current?.close();
handleClose();
router.push({
pathname: `${i18n.language}/profile`,
});
} catch (err) {
logMessage.error(err);
setIsFormError(true);
}
};

if (!questionNumber || !questionId || questions?.length <= 0) {
return (
<Dialog handleClose={handleClose} title={t("securityQuestionModal.title")} dialogRef={dialog}>
<Attention
type={AttentionTypes.ERROR}
isAlert={true}
classes="mb-6"
heading={t("securityQuestionModal.errors.unknownError.title")}
>
<p className="text-sm text-[#26374a]">
{t("securityQuestionModal.errors.unknownError.content")}
</p>
</Attention>
</Dialog>
);
}

return (
<Dialog
handleClose={handleClose}
title={t("securityQuestionModal.title")}
dialogRef={dialog}
actions={
<Button theme="primary" type="submit" onClick={handleSubmit}>
{t("securityQuestionModal.save")}
</Button>
}
>
<>
{/* TODO: probably will not need the error since already selected questions can be removed programmatically */}
{isFormError && (
<Attention
type={AttentionTypes.ERROR}
isAlert={true}
heading={t("securityQuestionModal.errors.formError.title")}
>
<p className="text-sm text-[#26374a]">
{t("securityQuestionModal.errors.formError.content")}
</p>
</Attention>
)}

{isFormWarning && (
<Attention type={AttentionTypes.WARNING} isAlert={true} isIcon={false} classes="mb-6">
<p className="text-sm font-bold text-[#26374a]">
{t("securityQuestionModal.errors.clickSave.title")}
</p>
<p className="text-sm text-[#26374a]">
{t("securityQuestionModal.errors.clickSave.content")}
</p>
</Attention>
)}

<p>{t("securityQuestionModal.requirmentsList.title")}</p>
<ul className="mb-6">
<li>{t("securityQuestionModal.requirmentsList.requirement1")}</li>
<li>{t("securityQuestionModal.requirmentsList.requirement2")}</li>
</ul>

<div className="mb-10">
<Label id="questionLabel" htmlFor="question" className="required" required>
{t("securityQuestionModal.questionLabel")} {questionNumber}
</Label>
<select
name="question"
id="questionLabel"
className="gc-dropdown mb-0 w-full rounded"
defaultValue={questionId}
ref={questionRef}
>
{questions.map((q: Question) => (
<option key={q.id} value={q.id}>
{q[langKey]}
</option>
))}
</select>
</div>

<div className="mb-10">
<Label
id="answerLabel"
htmlFor="answer"
className={`required ${isAnswerInputError ? "text-red" : ""}`}
required
>
{t("securityQuestionModal.answerLabel")} {questionNumber}
</Label>
{isAnswerInputError && (
<Attention
type={AttentionTypes.ERROR}
isAlert={true}
isIcon={false}
isSmall={true}
isLeftBorder={true}
>
<p className="text-sm font-bold text-[#26374a]">
{t("securityQuestionModal.errors.invalidInput")}
</p>
</Attention>
)}
<input
className={`gc-input-text w-full rounded ${isAnswerInputError ? "border-red" : ""}`}
id="answer"
name="answer"
type="text"
ref={answerRef}
onChange={_debouncedAnswerCheck}
/>
</div>
</>
</Dialog>
);
};
4 changes: 2 additions & 2 deletions components/form-builder/app/Publish.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,9 @@ export const Publish = () => {

const Icon = ({ checked }: { checked: boolean }) => {
return checked ? (
<CircleCheckIcon className="mr-2 w-9 fill-green-700 inline-block" title={t("completed")} />
<CircleCheckIcon className="mr-2 w-9 fill-green-700 inline-block" />
) : (
<CancelIcon className="mr-2 w-9 fill-red-700 w-9 h-9 inline-block" title={t("incomplete")} />
<CancelIcon className="mr-2 w-9 fill-red-700 w-9 h-9 inline-block" />
);
};

Expand Down
9 changes: 5 additions & 4 deletions components/form-builder/app/Template.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,13 @@ import SkipLink from "@components/globals/SkipLink";
import Footer from "@components/globals/Footer";
import Loader from "@components/globals/Loader";
import { useTemplateStore, TemplateStoreProvider } from "@components/form-builder/store";
import { LeftNavigation, Header } from "@components/form-builder/app";
import { LeftNavigation } from "@components/form-builder/app";
import { Language } from "../types";
import { TemplateApiProvider } from "../hooks";
import { ToastContainer } from "./shared/Toast";
import { RefStoreProvider } from "@lib/hooks/useRefStore";
import { useAccessControl } from "@lib/hooks/useAccessControl";
import { Header } from "@components/globals";

export const Template = ({
page,
Expand All @@ -33,9 +34,9 @@ export const Template = ({
<meta charSet="utf-8" />
<link rel="shortcut icon" href="/favicon.ico" type="image/x-icon" sizes="32x32" />
</Head>
<div className={`flex flex-col h-full ${className}`}>
<div className={`flex h-full flex-col ${className}`}>
<SkipLink />
<Header isFormBuilder={isFormBuilder} />
<Header context={isFormBuilder ? "formBuilder" : "default"} />
{page}
<Footer displayFormBuilderFooter />
</div>
Expand Down Expand Up @@ -79,7 +80,7 @@ export const PageTemplate = ({

// Wait until the Template Store has fully hydrated before rendering the page
return hasHydrated ? (
<div className="mx-4 laptop:mx-32 desktop:mx-64 grow shrink-0 basis-auto">
<div className="mx-4 shrink-0 grow basis-auto laptop:mx-32 desktop:mx-64">
<ToastContainer />
<div>
{leftNav && <LeftNavigation backLink={backLink} />}
Expand Down
1 change: 0 additions & 1 deletion components/form-builder/app/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ export { LeftNavigation } from "./navigation/LeftNavigation";
export { PreviewNavigation } from "./navigation/PreviewNavigation";
export { EditNavigation } from "./navigation/EditNavigation";
export { Template, PageTemplate } from "./Template";
export { Header } from "./navigation/Header";
export { Start } from "./Start";
export { Preview } from "./Preview";
export { Publish } from "./Publish";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ describe("<Nagware />", () => {
/>
);

cy.get("[role=alert]").should("have.attr", "class").and("contain", "bg-[#f3e9e8]");
cy.get("[role=alert]").should("have.attr", "class").and("contain", "bg-red-50");
cy.get("[data-testid=numberOfSubmissions]").should("contain", "5");
});

Expand All @@ -38,7 +38,7 @@ describe("<Nagware />", () => {
/>
);

cy.get("[role=alert]").should("have.attr", "class").and("contain", "bg-[#f3e9e8]");
cy.get("[role=alert]").should("have.attr", "class").and("contain", "bg-red-50");
cy.get("[data-testid=numberOfSubmissions]").should("contain", "3");
});

Expand Down
16 changes: 12 additions & 4 deletions components/forms/Dropdown/Dropdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ import { ErrorMessage } from "@components/forms";
import { InputFieldProps } from "@lib/types";

interface DropdownProps extends InputFieldProps {
choices: string[];
children?: React.ReactElement;
choices?: string[];
}

interface DropdownOptionProps {
Expand All @@ -19,7 +20,7 @@ const DropdownOption = (props: DropdownOptionProps): React.ReactElement => {
};

export const Dropdown = (props: DropdownProps): React.ReactElement => {
const { id, className, choices, required, ariaDescribedBy } = props;
const { children, id, name, className, choices = [], required, ariaDescribedBy } = props;

const { t } = useTranslation("common");

Expand All @@ -42,12 +43,19 @@ export const Dropdown = (props: DropdownProps): React.ReactElement => {
data-testid="dropdown"
className={classes}
id={id}
{...(name && { name })}
required={required}
aria-describedby={ariaDescribedBy}
{...field}
>
{initialDropdownOption}
{options}
{children ? (
children
) : (
<>
{initialDropdownOption}
{options}
</>
)}
</select>
</>
);
Expand Down
Loading

0 comments on commit f6f3362

Please sign in to comment.