diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 7995913..bd63f3a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -59,4 +59,4 @@ jobs: npm run start & npm run test env: - ORY_KRATOS_URL: http://localhost:4455 + ORY_KRATOS_URL: http://localhost:4433 diff --git a/cypress/integration/pages.spec.js b/cypress/integration/pages.spec.js index dae8152..2e256ec 100644 --- a/cypress/integration/pages.spec.js +++ b/cypress/integration/pages.spec.js @@ -15,20 +15,10 @@ context("Ory Kratos pages", () => { cy.get('[name="method"]').should("exist") }) - it("can load the registration page", () => { + it("can load the registration page if eligibile", () => { + sessionStorage.setItem("eligible", "") cy.visit("/registration") - cy.get('[name="traits.email"]').type(email) - cy.get('[name="password"]').type(password) - cy.get('[name="method"]').click() - cy.location("pathname").should("eq", "/verification") - - cy.visit("/") - cy.get('[data-testid="logout"]').should( - "have.attr", - "aria-disabled", - "false", - ) - cy.get('[data-testid="session-content"]').should("contain.text", email) + cy.get('[name="traits.email"]').should("exist") }) it("can load the verification page", () => { diff --git a/data/eligibility-questionnaire.ts b/data/eligibility-questionnaire.ts index 1b4204e..cc9a47b 100644 --- a/data/eligibility-questionnaire.ts +++ b/data/eligibility-questionnaire.ts @@ -1,87 +1,86 @@ export const eligibilityQuestions = [ - { - field_name: "age", - form_name: "eligibility", - section_header: "", - field_type: "text", - field_label: "How old are you?", - select_choices_or_calculations: "", - field_note: "", - text_validation_type_or_show_slider_number: "", - text_validation_min: "", - text_validation_max: "", - identifier: "", - branching_logic: "", - required_field: "yes", - custom_alignment: "", - question_number: "", - matrix_group_name: "", - matrix_ranking: "", - field_annotation: "", - evaluated_logic: "" - }, - { - field_name: "city", - form_name: "eligibility", - section_header: "", - field_type: "text", - field_label: "What city do you live in?", - select_choices_or_calculations: "", - field_note: "", - text_validation_type_or_show_slider_number: "", - text_validation_min: "", - text_validation_max: "", - identifier: "", - branching_logic: "", - required_field: "yes", - custom_alignment: "", - question_number: "", - matrix_group_name: "", - matrix_ranking: "", - field_annotation: "", - evaluated_logic: "" - }, - { - field_name: "has_fitbit", - form_name: "eligibility", - section_header: "", - field_type: "text", - field_label: "Do you have a Fitbit?", - select_choices_or_calculations: "", - field_note: "", - text_validation_type_or_show_slider_number: "", - text_validation_min: "", - text_validation_max: "", - identifier: "", - branching_logic: "", - required_field: "yes", - custom_alignment: "", - question_number: "", - matrix_group_name: "", - matrix_ranking: "", - field_annotation: "", - evaluated_logic: "" - }, - { - field_name: "is_eligible", - form_name: "eligibility", - section_header: "", - field_type: "text", - field_label: "Eligible", - select_choices_or_calculations: "", - field_note: "", - text_validation_type_or_show_slider_number: "", - text_validation_min: "", - text_validation_max: "", - identifier: "", - branching_logic: "", - required_field: "", - custom_alignment: "", - question_number: "", - matrix_group_name: "", - matrix_ranking: "", - field_annotation: "", - evaluated_logic: "" - } - ] - \ No newline at end of file + { + field_name: "age", + form_name: "eligibility", + section_header: "", + field_type: "text", + field_label: "How old are you?", + select_choices_or_calculations: "", + field_note: "", + text_validation_type_or_show_slider_number: "", + text_validation_min: "", + text_validation_max: "", + identifier: "", + branching_logic: "", + required_field: "yes", + custom_alignment: "", + question_number: "", + matrix_group_name: "", + matrix_ranking: "", + field_annotation: "", + evaluated_logic: "", + }, + { + field_name: "city", + form_name: "eligibility", + section_header: "", + field_type: "text", + field_label: "What city do you live in?", + select_choices_or_calculations: "", + field_note: "", + text_validation_type_or_show_slider_number: "", + text_validation_min: "", + text_validation_max: "", + identifier: "", + branching_logic: "", + required_field: "yes", + custom_alignment: "", + question_number: "", + matrix_group_name: "", + matrix_ranking: "", + field_annotation: "", + evaluated_logic: "", + }, + { + field_name: "has_fitbit", + form_name: "eligibility", + section_header: "", + field_type: "text", + field_label: "Do you have a Fitbit?", + select_choices_or_calculations: "", + field_note: "", + text_validation_type_or_show_slider_number: "", + text_validation_min: "", + text_validation_max: "", + identifier: "", + branching_logic: "", + required_field: "yes", + custom_alignment: "", + question_number: "", + matrix_group_name: "", + matrix_ranking: "", + field_annotation: "", + evaluated_logic: "", + }, + { + field_name: "is_eligible", + form_name: "eligibility", + section_header: "", + field_type: "text", + field_label: "Eligible", + select_choices_or_calculations: "", + field_note: "", + text_validation_type_or_show_slider_number: "", + text_validation_min: "", + text_validation_max: "", + identifier: "", + branching_logic: "", + required_field: "", + custom_alignment: "", + question_number: "", + matrix_group_name: "", + matrix_ranking: "", + field_annotation: "", + evaluated_logic: "", + }, +] diff --git a/pages/eligibility.tsx b/pages/eligibility.tsx index 9c20896..41e7bb0 100644 --- a/pages/eligibility.tsx +++ b/pages/eligibility.tsx @@ -5,16 +5,21 @@ import Head from "next/head" import { useRouter } from "next/router" import { useEffect, useState } from "react" import { toast } from "react-toastify" -import { eligibilityQuestions } from "../data/eligibility-questionnaire" +import { eligibilityQuestions } from "../data/eligibility-questionnaire" // Import render helpers import { MarginCard, CardTitle, TextCenterButton } from "../pkg" +interface EligibilityFormProps { + questions: any[] + onSubmit: (event: React.FormEvent) => void +} + // Renders the eligibility page const Eligibility: NextPage = () => { const IS_ELIGIBLE = "yes" const router = useRouter() - const [eligibility, setEligibility] = useState(null) + const [eligibility, setEligibility] = useState() const questions: any[] = eligibilityQuestions const checkEligibility = async (values: any) => { @@ -69,7 +74,10 @@ const NotEligibleMessage = () => ( ) -const EligibilityForm = ({ questions, onSubmit }) => ( +const EligibilityForm: React.FC = ({ + questions, + onSubmit, +}) => ( Eligibility Screening
diff --git a/pages/index.tsx b/pages/index.tsx index 2504241..7e3071b 100644 --- a/pages/index.tsx +++ b/pages/index.tsx @@ -127,7 +127,11 @@ const Home: NextPage = () => { Below you will find the decoded Ory Session if you are logged in.

- + diff --git a/pages/login.tsx b/pages/login.tsx index e926352..2552da3 100644 --- a/pages/login.tsx +++ b/pages/login.tsx @@ -94,7 +94,7 @@ const Login: NextPage = () => { // If the previous handler did not catch the error it's most likely a form validation error if (err.response?.status === 400) { // Yup, it is! - setFlow(err.response?.data) + setFlow(err.response?.data as LoginFlow) return } diff --git a/pages/profile.tsx b/pages/profile.tsx index d7f2f00..58e0e8a 100644 --- a/pages/profile.tsx +++ b/pages/profile.tsx @@ -1,12 +1,17 @@ -import { SettingsFlow } from "@ory/client" +import { + SettingsFlow, + UiNode, + UiNodeInputAttributes, + UpdateSettingsFlowBody, +} from "@ory/client" import { AxiosError } from "axios" import type { NextPage } from "next" import Head from "next/head" import Link from "next/link" import { useRouter } from "next/router" import { ReactNode, useEffect, useState } from "react" -import { profileQuestions } from "../data/profile-questionnaire" +import { profileQuestions } from "../data/profile-questionnaire" import { ActionCard, CenterLink, @@ -24,12 +29,19 @@ interface Props { only?: Methods } -function ProfileCard({ - flow, - only, - children, -}: Props & { children: ReactNode }) { - return {children} +interface ProfileFormProps { + questions: any[] + onSubmit: (event: React.FormEvent) => void + handleChange: (event: React.ChangeEvent) => void + profile: any +} + +function ProfileCard({ children }: Props & { children: ReactNode }) { + return ( + + {children} + + ) } const Profile: NextPage = () => { @@ -66,9 +78,11 @@ const Profile: NextPage = () => { }) .then(({ data }) => { setFlow(data) - const csrfTokenFromHeaders = data.ui.nodes.find( - (node: any) => node.attributes.name === "csrf_token", - )?.attributes.value + const csrfTokenFromHeaders = ( + data.ui.nodes.find( + (node: any) => node.attributes.name === "csrf_token", + )?.attributes as UiNodeInputAttributes + ).value const traits = data.identity.traits setCsrfToken(csrfTokenFromHeaders || "") setTraits(traits) @@ -86,7 +100,7 @@ const Profile: NextPage = () => { const onSubmit = (event: React.FormEvent) => { event.preventDefault() - const updatedValues = { + const updatedValues: UpdateSettingsFlowBody = { csrf_token: csrfToken, method: "profile", traits: { @@ -115,12 +129,12 @@ const Profile: NextPage = () => { return } }) - .catch(handleFlowError(router, "profile", setFlow)) + .catch(handleFlowError(router, "settings", setFlow)) .catch(async (err: AxiosError) => { // If the previous handler did not catch the error it's most likely a form validation error if (err.response?.status === 400) { // Yup, it is! - setFlow(err.response?.data) + setFlow(err.response?.data as SettingsFlow) return } @@ -136,8 +150,7 @@ const Profile: NextPage = () => { Profile Page - - + User Information { ) } -const ProfileForm: React.FC = ({ +const ProfileForm: React.FC = ({ questions, onSubmit, handleChange, diff --git a/pages/recovery.tsx b/pages/recovery.tsx index de0cd49..cfdad09 100644 --- a/pages/recovery.tsx +++ b/pages/recovery.tsx @@ -47,7 +47,7 @@ const Recovery: NextPage = () => { // If the previous handler did not catch the error it's most likely a form validation error if (err.response?.status === 400) { // Yup, it is! - setFlow(err.response?.data) + setFlow(err.response?.data as RecoveryFlow) return } @@ -75,7 +75,7 @@ const Recovery: NextPage = () => { switch (err.response?.status) { case 400: // Status code 400 implies the form validation had an error - setFlow(err.response?.data) + setFlow(err.response?.data as RecoveryFlow) return } diff --git a/pages/registration.tsx b/pages/registration.tsx index 28bf884..5676d02 100644 --- a/pages/registration.tsx +++ b/pages/registration.tsx @@ -10,6 +10,7 @@ import { ActionCard, CenterLink, Flow, MarginCard, CardTitle } from "../pkg" import { handleFlowError } from "../pkg/errors" // Import the SDK import ory from "../pkg/sdk" +import { parseObject } from "../pkg/ui/helpers" // Renders the registration page const Registration: NextPage = () => { @@ -19,16 +20,19 @@ const Registration: NextPage = () => { // information about the form we need to render (e.g. username + password) const [flow, setFlow] = useState() const [eligibility, setEligibility] = useState() + const [projectId, setProjectId] = useState() // Get ?flow=... from the URL const { flow: flowId, return_to: returnTo } = router.query useEffect(() => { const eligible = sessionStorage.getItem("eligible") + const projectId = sessionStorage.getItem("project_id") if (eligible == null) { router.push("/eligibility") } setEligibility(eligible) + setProjectId(projectId) }, []) // In this effect we either initiate a new registration flow, or we fetch an existing registration flow. @@ -62,8 +66,12 @@ const Registration: NextPage = () => { const onSubmit = async (values: UpdateRegistrationFlowBody) => { const updatedValues = { - ...values, - traits: { eligibility: JSON.parse(eligibility) }, + ...parseObject(values), + transient_payload: { project_id: projectId }, + traits: { + ...parseObject(values).traits, + eligibility: JSON.parse(eligibility), + }, } await router // On submission, add the flow ID to the URL but do not navigate. This prevents the user loosing @@ -101,7 +109,7 @@ const Registration: NextPage = () => { // If the previous handler did not catch the error it's most likely a form validation error if (err.response?.status === 400) { // Yup, it is! - setFlow(err.response?.data) + setFlow(err.response?.data as RegistrationFlow) return } diff --git a/pages/settings.tsx b/pages/settings.tsx index 7ade8fd..0ea0d98 100644 --- a/pages/settings.tsx +++ b/pages/settings.tsx @@ -84,7 +84,7 @@ const Settings: NextPage = () => { .catch(handleFlowError(router, "settings", setFlow)) }, [flowId, router, router.isReady, returnTo, flow]) - const onSubmit = (values: UpdateSettingsFlowWithProfileMethod) => + const onSubmit = (values: UpdateSettingsFlowBody) => router // On submission, add the flow ID to the URL but do not navigate. This prevents the user loosing // his data when she/he reloads the page. @@ -121,7 +121,7 @@ const Settings: NextPage = () => { // If the previous handler did not catch the error it's most likely a form validation error if (err.response?.status === 400) { // Yup, it is! - setFlow(err.response?.data) + setFlow(err.response?.data as SettingsFlow) return } @@ -142,23 +142,13 @@ const Settings: NextPage = () => { Profile Management and Security Settings -
-

Profile Settings

- -
- -
- - -
+

Profile Settings

+

Change Password

diff --git a/pages/study-consent.tsx b/pages/study-consent.tsx index a9166f4..11a82c1 100644 --- a/pages/study-consent.tsx +++ b/pages/study-consent.tsx @@ -1,5 +1,6 @@ import { SettingsFlow, + UiNodeInputAttributes, UpdateSettingsFlowBody, UpdateSettingsFlowWithProfileMethod, } from "@ory/client" @@ -10,8 +11,8 @@ import Head from "next/head" import Link from "next/link" import { useRouter } from "next/router" import { ReactNode, useEffect, useState } from "react" -import { consentQuestions } from "../data/consent-questionnaire" +import { consentQuestions } from "../data/consent-questionnaire" import { ActionCard, CenterLink, @@ -29,12 +30,12 @@ interface Props { only?: Methods } -function SettingsCard({ - flow, - only, - children, -}: Props & { children: ReactNode }) { - return {children} +function StudyConsentCard({ children }: Props & { children: ReactNode }) { + return ( + + {children} + + ) } const StudyConsent: NextPage = () => { @@ -71,9 +72,11 @@ const StudyConsent: NextPage = () => { }) .then(({ data }) => { setFlow(data) - const csrfTokenFromHeaders = data.ui.nodes.find( - (node: any) => node.attributes.name === "csrf_token", - )?.attributes.value + const csrfTokenFromHeaders = ( + data.ui.nodes.find( + (node: any) => node.attributes.name === "csrf_token", + )?.attributes as UiNodeInputAttributes + ).value const traits = data.identity.traits setCsrfToken(csrfTokenFromHeaders || "") setTraits(traits) @@ -82,7 +85,7 @@ const StudyConsent: NextPage = () => { .catch(handleFlowError(router, "settings", setFlow)) }, [flowId, router, router.isReady, returnTo, flow]) - const handleChange = (event) => { + const handleChange = (event: React.ChangeEvent) => { setConsent({ ...consent, [event.target.name]: String(event.target.checked), @@ -91,7 +94,7 @@ const StudyConsent: NextPage = () => { const onSubmit = (event: React.FormEvent) => { event.preventDefault() - const updatedValues = { + const updatedValues: UpdateSettingsFlowBody = { csrf_token: csrfToken, method: "profile", traits: { @@ -120,12 +123,12 @@ const StudyConsent: NextPage = () => { return } }) - .catch(handleFlowError(router, "consent", setFlow)) + .catch(handleFlowError(router, "settings", setFlow)) .catch(async (err: AxiosError) => { // If the previous handler did not catch the error it's most likely a form validation error if (err.response?.status === 400) { // Yup, it is! - setFlow(err.response?.data) + setFlow(err.response?.data as SettingsFlow) return } @@ -141,8 +144,7 @@ const StudyConsent: NextPage = () => { Study Consent - - + Study Consent { handleChange={handleChange} consent={consent} /> - + Go back @@ -168,16 +170,18 @@ const ConsentForm: React.FC = ({ }) => { return (
- {questions.map((question, index) => { + {questions.map((question: any, index: number) => { if (question.field_type === "info") { return ( question.select_choices_or_calculations instanceof Array && - question.select_choices_or_calculations.map((info, idx) => ( -
- - {info.label} -
- )) + question.select_choices_or_calculations.map( + (info: any, idx: number) => ( +
+ + {info.label} +
+ ), + ) ) } else if (question.field_type === "checkbox") { return ( diff --git a/pages/study.tsx b/pages/study.tsx index d741e39..30844c0 100644 --- a/pages/study.tsx +++ b/pages/study.tsx @@ -2,8 +2,8 @@ import type { NextPage } from "next" import Head from "next/head" import { useRouter } from "next/router" import { useEffect, useState } from "react" -import { studyInfo } from "../data/study-questionnaire" +import { studyInfo } from "../data/study-questionnaire" // Import render helpers import { MarginCard, CardTitle, TextCenterButton, InnerCard } from "../pkg" @@ -17,6 +17,7 @@ const Study: NextPage = () => { if (router.isReady) { const { projectId } = router.query if (typeof projectId === "string") { + sessionStorage.setItem("project_id", projectId) setProjectId(projectId) } } @@ -31,12 +32,7 @@ const Study: NextPage = () => { {projectId} Research Study - + Join Now {/* */} @@ -48,16 +44,18 @@ const Study: NextPage = () => { const StudyInfo: React.FC = ({ questions }) => { return (
- {questions.map((question, index) => { + {questions.map((question: any, index: number) => { if (question.field_type === "info") { return ( question.select_choices_or_calculations instanceof Array && - question.select_choices_or_calculations.map((info, idx) => ( -
- - {info.label} -
- )) + question.select_choices_or_calculations.map( + (info: any, idx: number) => ( +
+ + {info.label} +
+ ), + ) ) } return null diff --git a/pages/verification.tsx b/pages/verification.tsx index 26dc847..2af6318 100644 --- a/pages/verification.tsx +++ b/pages/verification.tsx @@ -78,22 +78,21 @@ const Verification: NextPage = () => { setFlow(data) }) .catch((err: AxiosError) => { + const data = err.response?.data as VerificationFlow switch (err.response?.status) { case 400: // Status code 400 implies the form validation had an error - setFlow(err.response?.data) + setFlow(data) return case 410: - const newFlowID = err.response.data.use_flow_id router // On submission, add the flow ID to the URL but do not navigate. This prevents the user loosing // their data when they reload the page. - .push(`/verification?flow=${newFlowID}`, undefined, { + .push(`/verification?flow=${data.id}`, undefined, { shallow: true, }) - ory - .getVerificationFlow({ id: newFlowID }) + .getVerificationFlow({ id: data.id }) .then(({ data }) => setFlow(data)) return } diff --git a/pkg/errors.tsx b/pkg/errors.tsx index dc3d8f0..4910f64 100644 --- a/pkg/errors.tsx +++ b/pkg/errors.tsx @@ -1,3 +1,7 @@ +import { + ErrorAuthenticatorAssuranceLevelNotSatisfied, + FlowError, +} from "@ory/client" import { AxiosError } from "axios" import { NextRouter } from "next/router" import { Dispatch, SetStateAction } from "react" @@ -10,13 +14,16 @@ export function handleGetFlowError( resetFlow: Dispatch>, ) { return async (err: AxiosError) => { - switch (err.response?.data.error?.id) { + const data = err.response?.data as FlowError + switch (data.id) { case "session_inactive": await router.push("/login?return_to=" + window.location.href) return case "session_aal2_required": - if (err.response?.data.redirect_browser_to) { - const redirectTo = new URL(err.response?.data.redirect_browser_to) + const e = err.response + ?.data as ErrorAuthenticatorAssuranceLevelNotSatisfied + if (e.redirect_browser_to) { + const redirectTo = new URL(e.redirect_browser_to) if (flowType === "settings") { redirectTo.searchParams.set("return_to", window.location.href) } @@ -32,7 +39,7 @@ export function handleGetFlowError( return case "session_refresh_required": // We need to re-authenticate to perform this action - window.location.href = err.response?.data.redirect_browser_to + await router.push("/") return case "self_service_flow_return_to_forbidden": // The flow expired, let's request a new one. @@ -61,7 +68,7 @@ export function handleGetFlowError( return case "browser_location_change_required": // Ory Kratos asked us to point the user to this URL. - window.location.href = err.response.data.redirect_browser_to + await router.push("/") return } diff --git a/pkg/ui/Flow.tsx b/pkg/ui/Flow.tsx index 2769932..6e00060 100644 --- a/pkg/ui/Flow.tsx +++ b/pkg/ui/Flow.tsx @@ -4,6 +4,7 @@ import { RegistrationFlow, SettingsFlow, UiNode, + UiNodeInputAttributes, UpdateLoginFlowBody, UpdateRecoveryFlowBody, UpdateRegistrationFlowBody, @@ -108,10 +109,18 @@ export class Flow extends Component, State> { if (!flow) { return [] } - return flow.ui.nodes.filter(({ group }) => { - if (!only) { - return true + return flow.ui.nodes.filter(({ group, attributes }) => { + if (!only) return true + + const isEmailNode = (attrs: UiNodeInputAttributes) => + ["email", "submit"].includes(attrs.type) || group === "default" + + if (only === "profile") { + return ( + isEmailNode(attributes as UiNodeInputAttributes) && group === only + ) } + return group === "default" || group === only }) } diff --git a/pkg/ui/helpers.ts b/pkg/ui/helpers.ts index fd9b3f7..a261c04 100644 --- a/pkg/ui/helpers.ts +++ b/pkg/ui/helpers.ts @@ -42,3 +42,16 @@ export const callWebauthnFunction = (functionBody: string) => { return intervalHandle } + +export function parseObject(input: any): any { + return Object.keys(input).reduce((output, key) => { + const [mainKey, subKey] = key.split(".") + if (subKey) { + output[mainKey] = output[mainKey] || {} + output[mainKey][subKey] = input[key] + } else { + output[key] = input[key] + } + return output + }, {} as any) +} diff --git a/styles/globals.css b/styles/globals.css index e627b09..fb23b43 100644 --- a/styles/globals.css +++ b/styles/globals.css @@ -98,8 +98,12 @@ input { } .codebox { - white-space: pre-wrap; /* Allows long lines to be wrapped */ - overflow: auto; /* Adds scrollbars if necessary */ - word-break: break-word; /* Breaks words if necessary to prevent overflow */ - max-width: 100%; /* Ensures the codebox does not exceed its container width */ -} \ No newline at end of file + white-space: pre-wrap; /* Allows long lines to be wrapped */ + overflow: auto; /* Adds scrollbars if necessary */ + word-break: break-word; /* Breaks words if necessary to prevent overflow */ + max-width: 100%; /* Ensures the codebox does not exceed its container width */ +} + +.cardMargin { + margin-top: 80px !important; +}