diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d045c2b..4f140a0 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -27,4 +27,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/consent-questionnaire.ts b/data/consent-questionnaire.ts new file mode 100644 index 0000000..4056ae1 --- /dev/null +++ b/data/consent-questionnaire.ts @@ -0,0 +1,55 @@ +export const consentQuestions = [ + { + field_name: "consent", + form_name: "consent", + section_header: "", + field_type: "info", + field_label: "", + select_choices_or_calculations: [ + { + label: + "We invite you to take part in this research study. This Participant Information Questionnaire will explain the study to help you decide whether you want to take part. It is entirely up to you whether or not to join the study.", + code: "Patient Information Sheet", + }, + { + label: + "If you join the study you will contribute to research that might help find ways to improve health and medical care. However, taking part does not give direct benefits. You will be able to see the data being recorded each day, to give you information on your activity and mobility.", + code: "Data Information", + }, + ], + 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: "2MW", + matrix_ranking: "", + field_annotation: "", + evaluated_logic: "", + }, + { + field_name: "has_consent", + form_name: "consent", + section_header: "", + field_type: "checkbox", + field_label: "I give my consent to take part in this study.", + 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: "", + }, +] diff --git a/data/eligibility-questionnaire.ts b/data/eligibility-questionnaire.ts new file mode 100644 index 0000000..cc9a47b --- /dev/null +++ b/data/eligibility-questionnaire.ts @@ -0,0 +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: "", + }, +] diff --git a/data/profile-questionnaire.ts b/data/profile-questionnaire.ts new file mode 100644 index 0000000..0605bfc --- /dev/null +++ b/data/profile-questionnaire.ts @@ -0,0 +1,23 @@ +export const profileQuestions = [ + { + field_name: "first_name", + form_name: "profile", + field_type: "text", + field_label: "First Name", + required_field: "yes", + }, + { + field_name: "last_name", + form_name: "profile", + field_type: "text", + field_label: "Last Name", + required_field: "yes", + }, + { + field_name: "address", + form_name: "profile", + field_type: "text", + field_label: "Address", + required_field: "yes", + }, +] diff --git a/data/study-questionnaire.ts b/data/study-questionnaire.ts new file mode 100644 index 0000000..6b0d067 --- /dev/null +++ b/data/study-questionnaire.ts @@ -0,0 +1,29 @@ +export const studyInfo = [ + { + field_name: "consent", + form_name: "consent", + section_header: "", + field_type: "info", + field_label: "", + select_choices_or_calculations: [ + { + label: + "This study aims to understand the effects of eating disorders. Participants will be askeed to fill out questionnaires and undergo non-invaise tests over the course of one year.", + code: "Study Overview", + }, + ], + 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: "2MW", + matrix_ranking: "", + field_annotation: "", + evaluated_logic: "", + }, +] diff --git a/package-lock.json b/package-lock.json index fd6fe31..475b492 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,7 @@ "name": "@ory/integration-react", "version": "0.0.1", "dependencies": { - "@ory/client": "^0.0.0-next.f88d10559361", + "@ory/client": "1.6.2", "@ory/integrations": "0.2.8", "@ory/themes": "~0.0.101", "classnames": "^2.3.1", @@ -1104,20 +1104,11 @@ } }, "node_modules/@ory/client": { - "version": "0.0.0-next.f88d10559361", - "resolved": "https://registry.npmjs.org/@ory/client/-/client-0.0.0-next.f88d10559361.tgz", - "integrity": "sha512-mItHDBAiefd0wdSxduVDYvnEUiBqJuiDos3P0KVyBlg9d7G++c0Bkz0lUBtqwWP5LWwQoFULAa40QJfgjFQNXw==", - "deprecated": "An incorrect version was published.", + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/@ory/client/-/client-1.6.2.tgz", + "integrity": "sha512-eeSkFZsrX/hLaariBg2I9PQWueE9IVAV3Tps5UE7CYEvrGziFB1zdv8joQDGMss5O3Yv/CSlSf4rOwTeENDqBg==", "dependencies": { - "axios": "^0.26.1" - } - }, - "node_modules/@ory/client/node_modules/axios": { - "version": "0.26.1", - "resolved": "https://registry.npmjs.org/axios/-/axios-0.26.1.tgz", - "integrity": "sha512-fPwcX4EvnSHuInCMItEhAGnaSEXRBjtzh9fOtsE6E1G6p7vl7edEeZe11QHf18+6+9gR5PbKV/sGKNaD8YaMeA==", - "dependencies": { - "follow-redirects": "^1.14.8" + "axios": "^1.6.1" } }, "node_modules/@ory/integrations": { @@ -1319,6 +1310,14 @@ "axios": "^0.21.4" } }, + "node_modules/@ory/integrations/node_modules/axios": { + "version": "0.21.4", + "resolved": "https://registry.npmjs.org/axios/-/axios-0.21.4.tgz", + "integrity": "sha512-ut5vewkiu8jjGBdqpM44XxjuCjq9LAKeHVmoVfHVzy8eHgxxq8SbAVQNovDA8mVi05kP0Ea/n/UzcSHcTJQfNg==", + "dependencies": { + "follow-redirects": "^1.14.0" + } + }, "node_modules/@ory/integrations/node_modules/next": { "version": "13.2.4", "resolved": "https://registry.npmjs.org/next/-/next-13.2.4.tgz", @@ -2045,13 +2044,33 @@ } }, "node_modules/axios": { - "version": "0.21.4", - "resolved": "https://registry.npmjs.org/axios/-/axios-0.21.4.tgz", - "integrity": "sha512-ut5vewkiu8jjGBdqpM44XxjuCjq9LAKeHVmoVfHVzy8eHgxxq8SbAVQNovDA8mVi05kP0Ea/n/UzcSHcTJQfNg==", + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.2.tgz", + "integrity": "sha512-2A8QhOMrbomlDuiLeK9XibIBzuHeRcqqNOHp0Cyp5EoJ1IFDh+XZH3A6BkXtv0K4gFGCI0Y4BM7B1wOEi0Rmgw==", "dependencies": { - "follow-redirects": "^1.14.0" + "follow-redirects": "^1.15.6", + "form-data": "^4.0.0", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/axios/node_modules/form-data": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", + "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" } }, + "node_modules/axios/node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" + }, "node_modules/axobject-query": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-2.2.0.tgz", @@ -3977,9 +3996,9 @@ "deprecated": "flatten is deprecated in favor of utility frameworks such as lodash." }, "node_modules/follow-redirects": { - "version": "1.14.9", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.14.9.tgz", - "integrity": "sha512-MQDfihBQYMcyy5dhRDJUHcw7lb2Pv/TuE6xP1vyraLukNDHKbDxDNaOE3NbCAdKQApno+GPRyo1YAp89yCjK4w==", + "version": "1.15.6", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.6.tgz", + "integrity": "sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==", "funding": [ { "type": "individual", @@ -9227,21 +9246,11 @@ } }, "@ory/client": { - "version": "0.0.0-next.f88d10559361", - "resolved": "https://registry.npmjs.org/@ory/client/-/client-0.0.0-next.f88d10559361.tgz", - "integrity": "sha512-mItHDBAiefd0wdSxduVDYvnEUiBqJuiDos3P0KVyBlg9d7G++c0Bkz0lUBtqwWP5LWwQoFULAa40QJfgjFQNXw==", + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/@ory/client/-/client-1.6.2.tgz", + "integrity": "sha512-eeSkFZsrX/hLaariBg2I9PQWueE9IVAV3Tps5UE7CYEvrGziFB1zdv8joQDGMss5O3Yv/CSlSf4rOwTeENDqBg==", "requires": { - "axios": "^0.26.1" - }, - "dependencies": { - "axios": { - "version": "0.26.1", - "resolved": "https://registry.npmjs.org/axios/-/axios-0.26.1.tgz", - "integrity": "sha512-fPwcX4EvnSHuInCMItEhAGnaSEXRBjtzh9fOtsE6E1G6p7vl7edEeZe11QHf18+6+9gR5PbKV/sGKNaD8YaMeA==", - "requires": { - "follow-redirects": "^1.14.8" - } - } + "axios": "^1.6.1" } }, "@ory/integrations": { @@ -9340,6 +9349,14 @@ "axios": "^0.21.4" } }, + "axios": { + "version": "0.21.4", + "resolved": "https://registry.npmjs.org/axios/-/axios-0.21.4.tgz", + "integrity": "sha512-ut5vewkiu8jjGBdqpM44XxjuCjq9LAKeHVmoVfHVzy8eHgxxq8SbAVQNovDA8mVi05kP0Ea/n/UzcSHcTJQfNg==", + "requires": { + "follow-redirects": "^1.14.0" + } + }, "next": { "version": "13.2.4", "resolved": "https://registry.npmjs.org/next/-/next-13.2.4.tgz", @@ -9859,11 +9876,30 @@ "dev": true }, "axios": { - "version": "0.21.4", - "resolved": "https://registry.npmjs.org/axios/-/axios-0.21.4.tgz", - "integrity": "sha512-ut5vewkiu8jjGBdqpM44XxjuCjq9LAKeHVmoVfHVzy8eHgxxq8SbAVQNovDA8mVi05kP0Ea/n/UzcSHcTJQfNg==", + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.2.tgz", + "integrity": "sha512-2A8QhOMrbomlDuiLeK9XibIBzuHeRcqqNOHp0Cyp5EoJ1IFDh+XZH3A6BkXtv0K4gFGCI0Y4BM7B1wOEi0Rmgw==", "requires": { - "follow-redirects": "^1.14.0" + "follow-redirects": "^1.15.6", + "form-data": "^4.0.0", + "proxy-from-env": "^1.1.0" + }, + "dependencies": { + "form-data": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", + "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", + "requires": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "mime-types": "^2.1.12" + } + }, + "proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" + } } }, "axobject-query": { @@ -11291,9 +11327,9 @@ "integrity": "sha512-dVsPA/UwQ8+2uoFe5GHtiBMu48dWLTdsuEd7CKGlZlD78r1TTWBvDuFaFGKCo/ZfEr95Uk56vZoX86OsHkUeIg==" }, "follow-redirects": { - "version": "1.14.9", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.14.9.tgz", - "integrity": "sha512-MQDfihBQYMcyy5dhRDJUHcw7lb2Pv/TuE6xP1vyraLukNDHKbDxDNaOE3NbCAdKQApno+GPRyo1YAp89yCjK4w==" + "version": "1.15.6", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.6.tgz", + "integrity": "sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==" }, "forever-agent": { "version": "0.6.1", diff --git a/package.json b/package.json index f3c5970..9ff242c 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ "module": "dist/index.mjs", "typings": "dist/index.d.ts", "dependencies": { - "@ory/client": "^0.0.0-next.f88d10559361", + "@ory/client": "1.6.2", "@ory/integrations": "0.2.8", "@ory/themes": "~0.0.101", "classnames": "^2.3.1", diff --git a/pages/api/consent.ts b/pages/api/consent.ts new file mode 100644 index 0000000..5632cf5 --- /dev/null +++ b/pages/api/consent.ts @@ -0,0 +1,80 @@ +import { Configuration, OAuth2Api } from "@ory/client" +import { NextApiRequest, NextApiResponse } from "next" + +const hydra = new OAuth2Api( + new Configuration({ + basePath: process.env.HYDRA_ADMIN_URL, + baseOptions: { + "X-Forwarded-Proto": "https", + withCredentials: true, + }, + }), +) + +// Helper function to extract session data +const extractSession = (identity: any, grantScope: string[]) => { + const session: any = { + access_token: { + roles: identity.metadata_public.roles, + scope: identity.metadata_public.scope, + authorities: identity.metadata_public.authorities, + sources: identity.metadata_public.sources, + user_name: identity.metadata_public.mp_login, + }, + id_token: {}, + } + return session +} + +export default async (req: NextApiRequest, res: NextApiResponse) => { + const { consentChallenge, consentAction, grantScope, remember, identity } = + req.body + + try { + if (req.method === "GET") { + const { consent_challenge } = req.query + const response = await hydra.getOAuth2ConsentRequest({ + consentChallenge: String(consent_challenge), + }) + return res.status(200).json(response.data) + } else { + if (!consentChallenge || !consentAction) { + return res.status(400).json({ error: "Missing required parameters" }) + } + if (consentAction === "accept") { + const { data: body } = await hydra.getOAuth2ConsentRequest({ + consentChallenge, + }) + const session = extractSession(identity, grantScope) + const acceptResponse = await hydra.acceptOAuth2ConsentRequest({ + consentChallenge, + acceptOAuth2ConsentRequest: { + grant_scope: grantScope, + grant_access_token_audience: body.requested_access_token_audience, + session, + remember: Boolean(remember), + remember_for: 3600, + }, + }) + return res + .status(200) + .json({ redirect_to: acceptResponse.data.redirect_to }) + } else { + const rejectResponse = await hydra.rejectOAuth2ConsentRequest({ + consentChallenge, + rejectOAuth2Request: { + error: "access_denied", + error_description: "The resource owner denied the request", + }, + }) + + return res + .status(200) + .json({ redirect_to: rejectResponse.data.redirect_to }) + } + } + } catch (error) { + console.error(error) + return res.status(500).json({ error: "Internal server error" }) + } +} diff --git a/pages/consent.tsx b/pages/consent.tsx new file mode 100644 index 0000000..fa202ec --- /dev/null +++ b/pages/consent.tsx @@ -0,0 +1,178 @@ +import { useRouter } from "next/router" +import React, { useEffect, useState } from "react" + +import { MarginCard, CardTitle, TextCenterButton } from "../pkg" +import ory from "../pkg/sdk" + +const Consent = () => { + const router = useRouter() + const [consent, setConsent] = useState(null) + const [identity, setIdentity] = useState(null) + const [csrfToken, setCsrfToken] = useState("") + + useEffect(() => { + const { consent_challenge } = router.query + + const fetchSessionAndConsent = async () => { + try { + const sessionResponse = await ory.toSession() + const sessionData = sessionResponse.data + setIdentity(sessionData.identity) + + if (!consent_challenge) { + console.error("Consent challenge is missing.") + return + } + + const consentResponse = await fetch( + `/api/consent?consent_challenge=${consent_challenge}`, + ) + const consentData = await consentResponse.json() + + if (consentData.error) { + throw new Error(consentData.error) + } + + setConsent(consentData) + + // Automatically handle skipping consent if enabled + if (consentData.client?.skip_consent) { + console.log("Skipping consent, automatically submitting.") + const skipResponse = await fetch("/api/consent", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + consentChallenge: consent_challenge, + consentAction: "accept", + grantScope: [], + remember: false, + identity: sessionData.identity, + }), + }) + const skipData = await skipResponse.json() + + if (skipData.error) { + throw new Error(skipData.error) + } + + router.push(skipData.redirect_to) + } + } catch (error) { + console.error("Error fetching session or consent:", error) + } + } + + if (router.query.consent_challenge) { + fetchSessionAndConsent() + } + }, [router.query]) + + const handleSubmit = async (event: React.FormEvent) => { + event.preventDefault() + const form = event.target as HTMLFormElement + const formData = new FormData(form) + + const submitter = (event.nativeEvent as SubmitEvent) + .submitter as HTMLButtonElement + const consentAction = submitter.value + const consentChallenge = formData.get("consent_challenge") as string + const remember = !!formData.get("remember") + const grantScope = formData.getAll("grant_scope") as string[] + + if (!consentChallenge || !consentAction) { + console.error("Consent challenge or action is missing.") + return + } + + try { + const response = await fetch("/api/consent", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + consentChallenge, + consentAction, + grantScope, + remember, + identity, + }), + }) + const data = await response.json() + + if (data.error) { + console.error("Error submitting consent:", data.error) + return + } + + router.push(data.redirect_to) + } catch (error) { + console.error("Error during consent submission:", error) + } + } + + if (!consent) { + return
Loading...
+ } + + return ( + +
+ Consent Request +
+ + +
+ +

{consent.client.client_name || consent.client.client_id}

+
+
+ + {consent.requested_scope.map((scope: string) => ( +
+ + +
+ ))} +
+

+
+ + +
+

+ + +
+
+
+ ) +} + +export default Consent diff --git a/pages/eligibility.tsx b/pages/eligibility.tsx new file mode 100644 index 0000000..f997b24 --- /dev/null +++ b/pages/eligibility.tsx @@ -0,0 +1,114 @@ +import { RegistrationFlow, UpdateRegistrationFlowBody } from "@ory/client" +import { AxiosError } from "axios" +import type { NextPage } from "next" +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 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() + const [toastVisible, setToastVisible] = useState(false) + const questions: any[] = eligibilityQuestions + + const checkEligibility = async (values: any) => { + // Eligibility check + return values.is_eligible == IS_ELIGIBLE + } + + // In this effect we either initiate a new registration flow, or we fetch an existing registration flow. + useEffect(() => { + // If the router is not ready yet, or we already have a flow, do nothing. + if (!router.isReady) { + return + } + }) + + const onSubmit = async (event: React.FormEvent) => { + if (toastVisible == true) return + + event.preventDefault() + const formData = new FormData(event.currentTarget) + const formValues = Object.fromEntries(formData.entries()) + const eligible = await checkEligibility(formValues) + setEligibility(eligible) + + if (eligible) { + setToastVisible(true) + sessionStorage.setItem("eligible", JSON.stringify(formValues)) + toast.success("Congrats, you're eligible!", { + autoClose: 1000, + position: toast.POSITION.TOP_CENTER, + closeButton: false, + onClose: () => { + setToastVisible(false) + router.replace("/registration") + }, + }) + } + } + + return ( + <> + + Eligibility + + {eligibility === false ? ( + + ) : ( + + )} + + ) +} + +const NotEligibleMessage = () => ( + + Not Eligible +
Unfortunately, you are not eligible to join the study.
+ Back +
+) + +const EligibilityForm: React.FC = ({ + questions, + onSubmit, +}) => ( + + Eligibility Screening +
+ {questions.map((question) => { + const isRequired = question.required_field === "yes" + return ( +
+ + +
+ ) + })} +
+ +
+
+) + +export default Eligibility diff --git a/pages/index.tsx b/pages/index.tsx index 9ca84e9..43dc663 100644 --- a/pages/index.tsx +++ b/pages/index.tsx @@ -1,11 +1,11 @@ -import { Card, CardTitle, P, H2, H3, CodeBox } from "@ory/themes" +import { Card, P, H2, H3, CodeBox } from "@ory/themes" import { AxiosError } from "axios" import type { NextPage } from "next" import Head from "next/head" import { useRouter } from "next/router" import { useEffect, useState } from "react" -import { DocsButton, MarginCard, LogoutLink } from "../pkg" +import { DocsButton, MarginCard, LogoutLink, CardTitle } from "../pkg" import ory from "../pkg/sdk" const Home: NextPage = () => { @@ -51,47 +51,71 @@ const Home: NextPage = () => { - Welcome to Ory! -

- Welcome to the Ory Managed UI. This UI implements a run-of-the-mill - user interface for all self-service flows (login, registration, - recovery, verification, settings). The purpose of this UI is to help - you get started quickly. In the long run, you probably want to - implement your own custom user interface. -

+ RADAR Base Ory! +

Welcome to the RADAR Base self-enrolment portal.

-

Documentation

-

- Here are some useful documentation pieces that help you get - started. -

+

Pages

+ + + + +
@@ -103,58 +127,15 @@ const Home: NextPage = () => { Below you will find the decoded Ory Session if you are logged in.

- +
- - -

Other User Interface Screens

-
- - - - - - -
-
) } diff --git a/pages/login.tsx b/pages/login.tsx index c93ac7c..512db40 100644 --- a/pages/login.tsx +++ b/pages/login.tsx @@ -1,5 +1,4 @@ import { LoginFlow, UpdateLoginFlowBody } from "@ory/client" -import { CardTitle } from "@ory/themes" import { AxiosError } from "axios" import type { NextPage } from "next" import Head from "next/head" @@ -7,7 +6,14 @@ import Link from "next/link" import { useRouter } from "next/router" import { useEffect, useState } from "react" -import { ActionCard, CenterLink, LogoutLink, Flow, MarginCard } from "../pkg" +import { + ActionCard, + CenterLink, + LogoutLink, + Flow, + MarginCard, + CardTitle, +} from "../pkg" import { handleGetFlowError, handleFlowError } from "../pkg/errors" import ory from "../pkg/sdk" @@ -25,6 +31,7 @@ const Login: NextPage = () => { // AAL = Authorization Assurance Level. This implies that we want to upgrade the AAL, meaning that we want // to perform two-factor authentication/verification. aal, + login_challenge: loginChallenge, } = router.query // This might be confusing, but we want to show the user an option @@ -51,15 +58,25 @@ const Login: NextPage = () => { // Otherwise we initialize it ory .createBrowserLoginFlow({ - refresh: Boolean(refresh), + refresh: loginChallenge ? true : Boolean(refresh), aal: aal ? String(aal) : undefined, returnTo: returnTo ? String(returnTo) : undefined, + loginChallenge: loginChallenge ? String(loginChallenge) : undefined, }) .then(({ data }) => { setFlow(data) }) .catch(handleFlowError(router, "login", setFlow)) - }, [flowId, router, router.isReady, aal, refresh, returnTo, flow]) + }, [ + flowId, + router, + router.isReady, + aal, + refresh, + returnTo, + loginChallenge, + flow, + ]) const onSubmit = (values: UpdateLoginFlowBody) => router @@ -86,7 +103,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 new file mode 100644 index 0000000..58e0e8a --- /dev/null +++ b/pages/profile.tsx @@ -0,0 +1,204 @@ +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 { + ActionCard, + CenterLink, + Flow, + Messages, + Methods, + CardTitle, + InnerCard, +} from "../pkg" +import { handleFlowError } from "../pkg/errors" +import ory from "../pkg/sdk" + +interface Props { + flow?: SettingsFlow + only?: Methods +} + +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 = () => { + const [flow, setFlow] = useState() + + // Get ?flow=... from the URL + const router = useRouter() + const { flow: flowId, return_to: returnTo } = router.query + const [csrfToken, setCsrfToken] = useState("") + const [traits, setTraits] = useState() + const [profile, setProfile] = useState({}) + + useEffect(() => { + // If the router is not ready yet, or we already have a flow, do nothing. + if (!router.isReady || flow) { + return + } + + // If ?flow=.. was in the URL, we fetch it + if (flowId) { + ory + .getSettingsFlow({ id: String(flowId) }) + .then(({ data }) => { + setFlow(data) + }) + .catch(handleFlowError(router, "settings", setFlow)) + return + } + + // Otherwise we initialize it + ory + .createBrowserSettingsFlow({ + returnTo: String(returnTo || ""), + }) + .then(({ data }) => { + setFlow(data) + 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) + setProfile(traits.additional_information || {}) + }) + .catch(handleFlowError(router, "settings", setFlow)) + }, [flowId, router, router.isReady, returnTo, flow]) + + const handleChange = (event: React.ChangeEvent) => { + setProfile({ + ...profile, + [event.target.name]: event.target.value, + }) + } + + const onSubmit = (event: React.FormEvent) => { + event.preventDefault() + const updatedValues: UpdateSettingsFlowBody = { + csrf_token: csrfToken, + method: "profile", + traits: { + ...traits, + additional_information: profile, + }, + } + + return ( + router + // On submission, add the flow ID to the URL but do not navigate. This prevents the user losing + // his data when she/he reloads the page. + .push(`/`) + .then(() => + ory + .updateSettingsFlow({ + flow: String(flow?.id), + updateSettingsFlowBody: updatedValues, + }) + .then(({ data }) => { + // The settings have been saved and the flow was updated. Let's show it to the user! + setFlow(data) + + if (data.return_to) { + window.location.href = data.return_to + return + } + }) + .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 as SettingsFlow) + return + } + + return Promise.reject(err) + }), + ) + ) + } + + return ( + <> + + Profile Page + + + + User Information + + + + + Go back + + + + ) +} + +const ProfileForm: React.FC = ({ + questions, + onSubmit, + handleChange, + profile, +}) => { + return ( +
+ {questions.map((question, index) => { + if (question.field_type === "text") { + return ( +
+ + +
+ ) + } + return null + })} +
+ +
+ ) +} + +export default Profile diff --git a/pages/recovery.tsx b/pages/recovery.tsx index 9061793..cfdad09 100644 --- a/pages/recovery.tsx +++ b/pages/recovery.tsx @@ -1,5 +1,4 @@ import { RecoveryFlow, UpdateRecoveryFlowBody } from "@ory/client" -import { CardTitle } from "@ory/themes" import { AxiosError } from "axios" import type { NextPage } from "next" import Head from "next/head" @@ -7,7 +6,7 @@ import Link from "next/link" import { useRouter } from "next/router" import { useEffect, useState } from "react" -import { Flow, ActionCard, CenterLink, MarginCard } from "../pkg" +import { Flow, ActionCard, CenterLink, MarginCard, CardTitle } from "../pkg" import { handleFlowError } from "../pkg/errors" import ory from "../pkg/sdk" @@ -48,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 } @@ -76,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 cbee3ab..a578cb4 100644 --- a/pages/registration.tsx +++ b/pages/registration.tsx @@ -1,42 +1,60 @@ import { RegistrationFlow, UpdateRegistrationFlowBody } from "@ory/client" -import { CardTitle } from "@ory/themes" import { AxiosError } from "axios" import type { NextPage } from "next" import Head from "next/head" import { useRouter } from "next/router" -import { useEffect, useState } from "react" +import { useEffect, useRef, useState } from "react" // Import render helpers -import { ActionCard, CenterLink, Flow, MarginCard } from "../pkg" +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 = () => { + const isMounted = useRef(true) // Tracking the mounted status of the component across renders const router = useRouter() // The "flow" represents a registration process and contains // 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(() => { + return () => { + isMounted.current = false + } + }, []) + + 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. useEffect(() => { // If the router is not ready yet, or we already have a flow, do nothing. if (!router.isReady || flow) { return } - // If ?flow=.. was in the URL, we fetch it if (flowId) { ory .getRegistrationFlow({ id: String(flowId) }) .then(({ data }) => { // We received the flow - let's use its data and render the form! - setFlow(data) + if (isMounted.current) setFlow(data) }) .catch(handleFlowError(router, "registration", setFlow)) return @@ -48,12 +66,24 @@ const Registration: NextPage = () => { returnTo: returnTo ? String(returnTo) : undefined, }) .then(({ data }) => { - setFlow(data) + if (isMounted.current) setFlow(data) }) .catch(handleFlowError(router, "registration", setFlow)) }, [flowId, router, router.isReady, returnTo, flow]) const onSubmit = async (values: UpdateRegistrationFlowBody) => { + const project = { + id: projectId, + name: projectId, + eligibility: JSON.parse(eligibility), + } + const updatedValues = { + ...parseObject(values), + traits: { + ...parseObject(values).traits, + projects: [project], + }, + } await 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. @@ -62,7 +92,7 @@ const Registration: NextPage = () => { ory .updateRegistrationFlow({ flow: String(flow?.id), - updateRegistrationFlowBody: values, + updateRegistrationFlowBody: updatedValues, }) .then(async ({ data }) => { // If we ended up here, it means we are successfully signed up! @@ -90,7 +120,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 } @@ -101,18 +131,12 @@ const Registration: NextPage = () => { return ( <> - Create account - Ory NextJS Integration Example - + Create account - Create account + Create Your Account - - - Sign in - - ) } diff --git a/pages/settings.tsx b/pages/settings.tsx index 4d1dc9f..3aa327d 100644 --- a/pages/settings.tsx +++ b/pages/settings.tsx @@ -1,5 +1,10 @@ -import { SettingsFlow, UpdateSettingsFlowBody } from "@ory/client" -import { CardTitle, H3, P } from "@ory/themes" +import { + SettingsFlow, + UiNodeInputAttributes, + UpdateSettingsFlowBody, + UpdateSettingsFlowWithProfileMethod, +} from "@ory/client" +import { H3, P } from "@ory/themes" import { AxiosError } from "axios" import type { NextPage } from "next" import Head from "next/head" @@ -7,7 +12,14 @@ import Link from "next/link" import { useRouter } from "next/router" import { ReactNode, useEffect, useState } from "react" -import { ActionCard, CenterLink, Flow, Messages, Methods } from "../pkg" +import { + ActionCard, + CenterLink, + Flow, + Messages, + Methods, + CardTitle, +} from "../pkg" import { handleFlowError } from "../pkg/errors" import ory from "../pkg/sdk" @@ -42,6 +54,8 @@ const Settings: NextPage = () => { // Get ?flow=... from the URL const router = useRouter() const { flow: flowId, return_to: returnTo } = router.query + const [email, setEmail] = useState("") + const [csrfToken, setCsrfToken] = useState("") useEffect(() => { // If the router is not ready yet, or we already have a flow, do nothing. @@ -66,6 +80,13 @@ const Settings: NextPage = () => { returnTo: String(returnTo || ""), }) .then(({ data }) => { + const csrfTokenFromHeaders = ( + data.ui.nodes.find( + (node: any) => node.attributes.name === "csrf_token", + )?.attributes as UiNodeInputAttributes + ).value + setCsrfToken(csrfTokenFromHeaders || "") + setEmail(data.identity.traits.email) setFlow(data) }) .catch(handleFlowError(router, "settings", setFlow)) @@ -80,7 +101,10 @@ const Settings: NextPage = () => { ory .updateSettingsFlow({ flow: String(flow?.id), - updateSettingsFlowBody: values, + updateSettingsFlowBody: { + ...values, + csrf_token: csrfToken, + } as any, }) .then(({ data }) => { // The settings have been saved and the flow was updated. Let's show it to the user! @@ -108,7 +132,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 } @@ -140,7 +164,6 @@ const Settings: NextPage = () => {

Change Password

- {

Manage Social Sign In

-
diff --git a/pages/study-consent.tsx b/pages/study-consent.tsx new file mode 100644 index 0000000..9534140 --- /dev/null +++ b/pages/study-consent.tsx @@ -0,0 +1,258 @@ +import { + SettingsFlow, + UiNodeInputAttributes, + UpdateSettingsFlowBody, +} from "@ory/client" +import { H3, P } from "@ory/themes" +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 { consentQuestions } from "../data/consent-questionnaire" +import { + ActionCard, + CenterLink, + Flow, + Messages, + Methods, + CardTitle, + InnerCard, +} from "../pkg" +import { handleFlowError } from "../pkg/errors" +import ory from "../pkg/sdk" + +interface Props { + flow?: SettingsFlow + only?: Methods +} + +function StudyConsentCard({ children }: Props & { children: ReactNode }) { + return ( + + {children} + + ) +} + +const StudyConsent: NextPage = () => { + const [flow, setFlow] = useState() + + // Get ?flow=... from the URL + const router = useRouter() + const { flow: flowId, return_to: returnTo } = router.query + const [csrfToken, setCsrfToken] = useState("") + const [traits, setTraits] = useState() + const [projects, setProjects] = useState([]) + + useEffect(() => { + // If the router is not ready yet, or we already have a flow, do nothing. + if (!router.isReady || flow) { + return + } + + // If ?flow=.. was in the URL, we fetch it + if (flowId) { + ory + .getSettingsFlow({ id: String(flowId) }) + .then(({ data }) => { + setFlow(data) + }) + .catch(handleFlowError(router, "settings", setFlow)) + return + } + + // Otherwise we initialize it + ory + .createBrowserSettingsFlow({ + returnTo: String(returnTo || ""), + }) + .then(({ data }) => { + setFlow(data) + 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) + setProjects(traits.projects) + }) + .catch(handleFlowError(router, "settings", setFlow)) + }, [flowId, router, router.isReady, returnTo, flow]) + + const handleChange = ( + event: React.ChangeEvent, + projectId: string | null, + ) => { + const { name, checked } = event.target + + setProjects((prevProjects: any[]) => + prevProjects.map((project) => { + if (project.id === projectId) { + return { + ...project, + consent: { + ...project.consent, + [name]: String(checked), + }, + } + } + return project + }), + ) + } + + const onSubmit = ( + event: React.FormEvent, + projectId: string | null, + ) => { + event.preventDefault() + const updatedValues: UpdateSettingsFlowBody = { + csrf_token: csrfToken, + method: "profile", + traits: { + ...traits, + projects, + }, + } + + return ( + router + // On submission, add the flow ID to the URL but do not navigate. This prevents the user losing + // their data when they reload the page. + .push(`/`) + .then(() => + ory + .updateSettingsFlow({ + flow: String(flow?.id), + updateSettingsFlowBody: updatedValues, + }) + .then(({ data }) => { + // The settings have been saved and the flow was updated. Let's show it to the user! + setFlow(data) + + if (data.return_to) { + window.location.href = data.return_to + return + } + }) + .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 as SettingsFlow) + return + } + + return Promise.reject(err) + }), + ) + ) + } + + return ( + <> + + Study Consent + + + + Study Consent + + + + + Go back + + + + ) +} + +interface ConsentFormProps { + questions: any[] + onSubmit: ( + event: React.FormEvent, + projectId: string | null, + ) => void + handleChange: ( + event: React.ChangeEvent, + projectId: string | null, + ) => void + projects: any[] +} + +const ConsentForm: React.FC = ({ + questions, + onSubmit, + handleChange, + projects, +}) => { + return ( +
+ {projects.map((project) => ( +
+

{project.name}

+ {questions.map((question, index) => { + if (question.field_type === "info") { + return ( + question.select_choices_or_calculations instanceof Array && + question.select_choices_or_calculations.map( + (info: any, idx: number) => ( +
+ +
{info.label}
+
+
+ ), + ) + ) + } else if (question.field_type === "checkbox") { + return ( +
onSubmit(event, project.id)} + > +
+ handleChange(event, project.id)} + /> +
+
+ +
+
+ +
+ ) + } + return null + })} +
+ ))} +
+ ) +} + +export default StudyConsent diff --git a/pages/study.tsx b/pages/study.tsx new file mode 100644 index 0000000..cc0795a --- /dev/null +++ b/pages/study.tsx @@ -0,0 +1,75 @@ +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 render helpers +import { MarginCard, CardTitle, TextCenterButton, InnerCard } from "../pkg" + +// Renders the eligibility page +const Study: NextPage = () => { + const router = useRouter() + const [projectId, setProjectId] = useState(null) + + useEffect(() => { + // If the router is not ready yet, or we already have a flow, do nothing. + if (router.isReady) { + const { projectId } = router.query + if (typeof projectId === "string") { + sessionStorage.setItem("project_id", projectId) + setProjectId(projectId) + } + } + }) + + const handleNavigation = () => { + router.replace("/eligibility") + } + + return ( + <> + + Welcome + + + {projectId} Research Study + + + + Join Now + + {/* */} + + + ) +} + +const StudyInfo: React.FC = ({ questions }) => { + return ( +
+ {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: any, idx: number) => ( +
+ + {info.label} +
+ ), + ) + ) + } + return null + })} +
+ ) +} + +export default Study diff --git a/pages/verification.tsx b/pages/verification.tsx index 3c1d0c2..2af6318 100644 --- a/pages/verification.tsx +++ b/pages/verification.tsx @@ -1,5 +1,4 @@ import { VerificationFlow, UpdateVerificationFlowBody } from "@ory/client" -import { CardTitle } from "@ory/themes" import { AxiosError } from "axios" import type { NextPage } from "next" import Head from "next/head" @@ -7,7 +6,7 @@ import Link from "next/link" import { useRouter } from "next/router" import { useEffect, useState } from "react" -import { Flow, ActionCard, CenterLink, MarginCard } from "../pkg" +import { Flow, ActionCard, CenterLink, MarginCard, CardTitle } from "../pkg" import ory from "../pkg/sdk" const Verification: NextPage = () => { @@ -79,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..b590f71 100644 --- a/pkg/errors.tsx +++ b/pkg/errors.tsx @@ -1,3 +1,8 @@ +import { + ErrorAuthenticatorAssuranceLevelNotSatisfied, + ErrorBrowserLocationChangeRequired, + NeedsPrivilegedSessionError, +} from "@ory/client" import { AxiosError } from "axios" import { NextRouter } from "next/router" import { Dispatch, SetStateAction } from "react" @@ -10,67 +15,81 @@ export function handleGetFlowError( resetFlow: Dispatch>, ) { return async (err: AxiosError) => { - switch (err.response?.data.error?.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) - if (flowType === "settings") { - redirectTo.searchParams.set("return_to", window.location.href) + let error: + | ErrorBrowserLocationChangeRequired + | ErrorAuthenticatorAssuranceLevelNotSatisfied + | NeedsPrivilegedSessionError + const errorId = (err.response?.data as { error?: { id?: string } })?.error + ?.id + if (err.response && err.response.data) { + switch (errorId) { + case "session_inactive": + await router.push("/login?return_to=" + window.location.href) + return + case "session_aal2_required": + error = err.response + .data as ErrorAuthenticatorAssuranceLevelNotSatisfied + if (error.redirect_browser_to) { + const redirectTo = new URL(error.redirect_browser_to) + if (flowType === "settings") { + redirectTo.searchParams.set("return_to", window.location.href) + } + // 2FA is enabled and enforced, but user did not perform 2fa yet! + window.location.href = redirectTo.toString() + return } - // 2FA is enabled and enforced, but user did not perform 2fa yet! - window.location.href = redirectTo.toString() + await router.push("/login?aal=aal2&return_to=" + window.location.href) return - } - await router.push("/login?aal=aal2&return_to=" + window.location.href) - return - case "session_already_available": - // User is already signed in, let's redirect them home! - await router.push("/") - return - case "session_refresh_required": - // We need to re-authenticate to perform this action - window.location.href = err.response?.data.redirect_browser_to - return - case "self_service_flow_return_to_forbidden": - // The flow expired, let's request a new one. - toast.error("The return_to address is not allowed.") - resetFlow(undefined) - await router.push("/" + flowType) - return - case "self_service_flow_expired": - // The flow expired, let's request a new one. - toast.error("Your interaction expired, please fill out the form again.") - resetFlow(undefined) - await router.push("/" + flowType) - return - case "security_csrf_violation": - // A CSRF violation occurred. Best to just refresh the flow! - toast.error( - "A security violation was detected, please fill out the form again.", - ) - resetFlow(undefined) - await router.push("/" + flowType) - return - case "security_identity_mismatch": - // The requested item was intended for someone else. Let's request a new flow... - resetFlow(undefined) - await router.push("/" + flowType) - 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 - return - } + case "session_already_available": + // User is already signed in, let's redirect them home! + await router.push("/") + return + case "session_refresh_required": + error = err.response.data as NeedsPrivilegedSessionError + // We need to re-authenticate to perform this action + window.location.href = error.redirect_browser_to || "/" + return + case "self_service_flow_return_to_forbidden": + // The flow expired, let's request a new one. + toast.error("The return_to address is not allowed.") + resetFlow(undefined) + await router.push("/" + flowType) + return + case "self_service_flow_expired": + // The flow expired, let's request a new one. + toast.error( + "Your interaction expired, please fill out the form again.", + ) + resetFlow(undefined) + await router.push("/" + flowType) + return + case "security_csrf_violation": + // A CSRF violation occurred. Best to just refresh the flow! + toast.error( + "A security violation was detected, please fill out the form again.", + ) + resetFlow(undefined) + await router.push("/" + flowType) + return + case "security_identity_mismatch": + // The requested item was intended for someone else. Let's request a new flow... + resetFlow(undefined) + await router.push("/" + flowType) + return + case "browser_location_change_required": + error = err.response.data as ErrorBrowserLocationChangeRequired + // Ory Kratos asked us to point the user to this URL. + window.location.href = error.redirect_browser_to || "/" + return + } - switch (err.response?.status) { - case 410: - // The flow expired, let's request a new one. - resetFlow(undefined) - await router.push("/" + flowType) - return + switch (err.response?.status) { + case 410: + // The flow expired, let's request a new one. + resetFlow(undefined) + await router.push("/" + flowType) + return + } } // We are not able to handle the error? Return it. diff --git a/pkg/styled/index.tsx b/pkg/styled/index.tsx index 7fa0352..b3afb70 100644 --- a/pkg/styled/index.tsx +++ b/pkg/styled/index.tsx @@ -1,5 +1,6 @@ import { Card, + H2, LinkButton, typographyH2Styles, typographyLinkStyles, @@ -10,17 +11,36 @@ import styled from "styled-components" export const MarginCard = styled(Card)` margin-top: 70px; margin-bottom: 18px; + border-radius: 30px; + background-color: #fff !important; +` +export const InnerCard = styled(Card)` + margin: 8px 0; + color: #555; + text-align: left; + border-radius: 30px; + background-color: #f8f9fc !important; + width: 90%; + border: none; ` +export const CardTitle = styled(H2)` + color: #333; + text-align: center; + font-size: 24px; + font-weight: bold; +` export const ActionCard = styled(Card)` margin-bottom: 18px; + background-color: #fff !important; + border-radius: 20px; ` - export const CenterLink = styled.a` ${typographyH2Styles}; ${typographyLinkStyles}; text-align: center; font-size: 15px; + color: black !important; ` export const TextLeftButton = styled(LinkButton)` @@ -28,6 +48,9 @@ export const TextLeftButton = styled(LinkButton)` & .linkButton { box-sizing: border-box; + background-color: #706ef4; + color: #fff; + font-weight: bold; } & a { @@ -37,6 +60,32 @@ export const TextLeftButton = styled(LinkButton)` &:focus, &:visited { text-align: left; + background-color: #706ef4 !important; + color: #fff; + } + } +` + +export const TextCenterButton = styled(LinkButton)` + box-sizing: border-box; + + & .linkButton { + box-sizing: border-box; + background-color: #706ef4; + color: #fff; + font-weight: bold; + border-radius: 8px !important; + } + + & a { + &:hover, + &, + &:active, + &:focus, + &:visited { + text-align: center; + background-color: #706ef4 !important; + color: #fff; } } ` 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/NodeInputDefault.tsx b/pkg/ui/NodeInputDefault.tsx index 219c958..2c730e9 100644 --- a/pkg/ui/NodeInputDefault.tsx +++ b/pkg/ui/NodeInputDefault.tsx @@ -19,6 +19,7 @@ export function NodeInputDefault(props: NodeInputProps) { // Render a generic text input field. return ( { 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/public/image.png b/public/image.png new file mode 100644 index 0000000..5d432f1 Binary files /dev/null and b/public/image.png differ diff --git a/styles/flexboxgrid.css b/styles/flexboxgrid.css index 603506f..c2d6c5d 100644 --- a/styles/flexboxgrid.css +++ b/styles/flexboxgrid.css @@ -2,6 +2,7 @@ .container { margin-right: auto; margin-left: auto; + font-family: "Quicksand"; } .container-fluid { diff --git a/styles/globals.css b/styles/globals.css index 19f6014..fb23b43 100644 --- a/styles/globals.css +++ b/styles/globals.css @@ -1 +1,109 @@ @import "./flexboxgrid.css"; +@import url("https://fonts.googleapis.com/css2?family=Quicksand:wght@300..700&display=swap"); + +* { + font-family: "Quicksand" !important; +} + +html, +body { + background-color: #ededfc !important; +} + +button, +.button { + border-radius: 8px !important; + background-color: #706ef4 !important; + color: #fff !important; + font-weight: bold; + width: 100%; + border: none; + padding: 8px; + font-size: 16px; +} + +.consent-button { + border-radius: 8px !important; + background-color: #706ef4 !important; + color: #fff !important; + width: 30%; + border: none; + padding: 8px; + margin: 8px; + font-size: 16px; + font-weight: 600; +} + +.Toastify__close-button { + width: 10% !important; + border-radius: 30px !important; + padding: auto; +} + +.input { + border-radius: 10px !important; + font-size: 18px !important; + color: #aaa; + font-weight: bold !important; + input, + input:hover { + background-color: #f8f9fc; + border: none; + color: black; + padding: 8px; + } + span { + font-size: 12px; + } +} + +input { + background-color: #f8f9fc; + border: none; + color: black; + padding: 12px; + font-size: 16px !important; + width: 95%; + border-radius: 10px !important; + margin: 8px 0px !important; +} + +.inputLabel { + border-radius: 10px !important; + font-size: 18px !important; + color: #aaa; + font-weight: 600; +} + +.center { + text-align: center !important; + margin: 8px; +} + +.consent-form { + display: flex; + justify-content: center; /* Center the form elements horizontally */ + align-items: center !important; + margin-left: 20%; +} + +.inputLabelCheck { + display: flex; + align-items: center; + width: 100%; /* Ensure the label takes full width */ +} + +.checkbox { + width: 10%; /* Ensure the label takes full width */ +} + +.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 */ +} + +.cardMargin { + margin-top: 80px !important; +}