diff --git a/.env.example b/.env.example index 366e2f5f..394ccfee 100644 --- a/.env.example +++ b/.env.example @@ -7,8 +7,8 @@ RECAPTHA_API_KEY= RECAPTHA_PROJECT_ID= SITE_COOKIE_KEY= NEXT_PUBLIC_RECAPTCHA_SITE_KEY= -NEXT_PUBLIC_GOOGLE_ANALYTICS= +NEXT_PUBLIC_GTM_ID= CEDULA_TOKEN_API= CITIZENS_API_AUTH_KEY= -ORY_SDK_URL= +NEXT_PUBLIC_ORY_SDK_URL= ORY_SDK_TOKEN= diff --git a/.github/workflows/build-docker-image.yml b/.github/workflows/build-docker-image.yml index a988f0c6..539b95fe 100644 --- a/.github/workflows/build-docker-image.yml +++ b/.github/workflows/build-docker-image.yml @@ -87,7 +87,8 @@ jobs: push: true build-args: | NEXT_PUBLIC_RECAPTCHA_SITE_KEY=${{ secrets.NEXT_PUBLIC_RECAPTCHA_SITE_KEY }} - NEXT_PUBLIC_GOOGLE_ANALYTICS=${{ secrets.NEXT_PUBLIC_GOOGLE_ANALYTICS }} + NEXT_PUBLIC_GTM_ID=${{ secrets.NEXT_PUBLIC_GOOGLE_ANALYTICS }} + NEXT_PUBLIC_ORY_SDK_URL=${{ vars.NEXT_PUBLIC_ORY_SDK_URL }} secrets: | "AWS_EXPORTS_JSON=${{ secrets.AWS_EXPORTS_JSON }}" diff --git a/.github/workflows/cloudrun-deploy.yml b/.github/workflows/cloudrun-deploy.yml index c87876e1..87fb981c 100644 --- a/.github/workflows/cloudrun-deploy.yml +++ b/.github/workflows/cloudrun-deploy.yml @@ -66,7 +66,6 @@ jobs: service: ${{ env.GITHUB_REPOSITORY_NAME_PART_SLUG }}-${{ needs.versioning.outputs.version || env.GITHUB_HEAD_REF_SLUG || env.GITHUB_REF_SLUG }} region: ${{ inputs.region }} env_vars: | - ORY_SDK_URL=${{ secrets.ORY_SDK_URL }}, ORY_SDK_TOKEN=${{ secrets.ORY_SDK_TOKEN }}, CEDULA_API=${{ secrets.CEDULA_API }}, CEDULA_API_KEY=${{ secrets.CEDULA_API_KEY }}, diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index c0c9e11e..675e24ec 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -41,7 +41,7 @@ env: jobs: test: name: Test with Node.js ${{ matrix.node }} - timeout-minutes: 5 + timeout-minutes: 10 runs-on: ubuntu-latest strategy: @@ -61,6 +61,7 @@ jobs: uses: actions/setup-node@v3.7.0 with: node-version: ${{ matrix.node }} + cache: yarn - name: Audit for vulnerabilities run: npx audit-ci@^6 --config ./audit-ci.jsonc diff --git a/Dockerfile b/Dockerfile index ac776298..aa806904 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,7 +8,7 @@ FROM node:${NODE_VERSION}-alpine${ALPINE_VERSION} AS base ARG WORK_DIR ARG APP_ENV=production -ENV PORT=3000 +ARG PORT=3000 ENV WORK_DIR=${WORK_DIR} ENV NODE_ENV=${APP_ENV} ENV NEXT_TELEMETRY_DISABLED=1 @@ -20,8 +20,11 @@ WORKDIR ${WORK_DIR} ARG NEXT_PUBLIC_RECAPTCHA_SITE_KEY ENV NEXT_PUBLIC_RECAPTCHA_SITE_KEY=${NEXT_PUBLIC_RECAPTCHA_SITE_KEY} -ARG NEXT_PUBLIC_GOOGLE_ANALYTICS -ENV NEXT_PUBLIC_GOOGLE_ANALYTICS=${NEXT_PUBLIC_GOOGLE_ANALYTICS} +ARG NEXT_PUBLIC_GTM_ID +ENV NEXT_PUBLIC_GTM_ID=${NEXT_PUBLIC_GTM_ID} + +ARG NEXT_PUBLIC_ORY_SDK_URL +ENV NEXT_PUBLIC_ORY_SDK_URL=${NEXT_PUBLIC_ORY_SDK_URL} # ===================== Install Deps ===================== FROM base as deps @@ -60,4 +63,7 @@ USER nextjs EXPOSE ${PORT} +ENV PORT ${PORT} +ENV HOSTNAME 0.0.0.0 + CMD ["node", "server.js"] diff --git a/next.config.js b/next.config.js index f83dccba..7b0a429d 100644 --- a/next.config.js +++ b/next.config.js @@ -1,7 +1,21 @@ /** @type {import('next').NextConfig} */ const nextConfig = { + experimental: { + serverActions: true, + }, reactStrictMode: true, output: 'standalone', + webpack: (config, { webpack, isServer, nextRuntime }) => { + // Avoid AWS SDK Node.js require issue + if (isServer && nextRuntime === 'nodejs') + config.plugins.push( + new webpack.IgnorePlugin({ resourceRegExp: /^aws-crt$/ }), + ); + if (!isServer) { + config.externals = ['dtrace-provider']; + } + return config; + }, }; module.exports = nextConfig; diff --git a/package.json b/package.json index 0c9822df..4dcbc323 100644 --- a/package.json +++ b/package.json @@ -23,52 +23,55 @@ "dependencies": { "@aws-amplify/ui-react-liveness": "^2.0.1", "@aws-sdk/client-rekognition": "^3.379.1", - "@babel/core": "7.22.9", + "@babel/core": "^7.22.11", "@emotion/react": "^11.11.1", "@emotion/styled": "^11.10.6", - "@google-cloud/logging-bunyan": "^4.2.2", - "@hookform/resolvers": "3.1.1", - "@mui/icons-material": "^5.14.1", - "@mui/material": "^5.14.2", + "@google-cloud/logging-bunyan": "^5.0.0", + "@hookform/resolvers": "^3.3.1", + "@mui/icons-material": "^5.14.3", + "@mui/material": "^5.14.5", "@ory/client": "^1.1.41", "@ory/integrations": "^1.1.4", + "@thgh/next-gtm": "^0.1.4", "@types/bunyan": "^1.8.8", "@types/react-gtm-module": "^2.0.1", "aws-amplify": "^5.3.5", "axios": "^1.4.0", "bunyan": "^1.8.15", "check-password-strength": "^2.0.7", - "cookie": "^0.5.0", "cryptr": "^6.2.0", "eslint": "^8.46.0", - "eslint-config-next": "13.4.12", + "eslint-config-next": "^13.4.19", "hibp": "^13.0.0", - "next": "13.4.12", + "next": "13.4.19", "next-recaptcha-v3": "^1.2.0", "react": "18.2.0", "react-dom": "18.2.0", "react-gtm-module": "^2.0.11", - "react-hook-form": "7.45.2", + "react-hook-form": "7.45.4", "react-imask": "^7.1.3", "sharp": "^0.32.4", + "source-map-support": "^0.5.21", "typescript": "5.1.6", "yup": "^1.2.0" }, "devDependencies": { "@types/cookie": "^0.5.1", - "@types/node": "^18.17.0", + "@types/node": "^20.5.7", "@types/react": "^18.2.17", "@types/react-dom": "^18.2.7", "@types/react-google-recaptcha": "^2.1.5", "@typescript-eslint/eslint-plugin": "^6.2.0", "@typescript-eslint/parser": "^6.2.0", - "eslint-config-prettier": "^8.9.0", + "aws-crt": "^1.18.0", + "encoding": "^0.1.13", + "eslint-config-prettier": "^9.0.0", "eslint-import-resolver-typescript": "^3.5.5", "eslint-plugin-import": "^2.28.0", "eslint-plugin-prettier": "^5.0.0", "husky": "^8.0.3", "install-peers": "^1.0.4", - "lint-staged": "^13.2.3", + "lint-staged": "^14.0.0", "prettier": ">=3.0.0" } } diff --git a/src/actions/index.ts b/src/actions/index.ts new file mode 100644 index 00000000..11cfae50 --- /dev/null +++ b/src/actions/index.ts @@ -0,0 +1 @@ +export { setCookie } from './set-cookie'; diff --git a/src/actions/set-cookie.ts b/src/actions/set-cookie.ts new file mode 100644 index 00000000..37222ac7 --- /dev/null +++ b/src/actions/set-cookie.ts @@ -0,0 +1,15 @@ +'use server'; + +import { cookies } from 'next/headers'; + +export async function setCookie() { + const key = process.env.SITE_COOKIE_KEY as string; + + cookies().set('token', key, { + httpOnly: true, + secure: true, + maxAge: 60 * 60 * 10, + sameSite: 'strict', + path: '/', + }); +} diff --git a/src/app/api/biometric/route.ts b/src/app/api/biometric/route.ts new file mode 100644 index 00000000..437807c4 --- /dev/null +++ b/src/app/api/biometric/route.ts @@ -0,0 +1,111 @@ +import { NextRequest, NextResponse } from 'next/server'; +import axios from 'axios'; + +import { getRekognitionClient } from '@/helpers'; +import logger from '@/lib/logger'; + +import { + LIVENESS_LOW_CONFIDENCE_ERROR, + LIVENESS_NO_MATCH_ERROR, +} from '@/constants'; + +export async function GET( + req: NextRequest, + res: NextResponse, +): Promise { + const http = axios.create({ + baseURL: process.env.JCE_PHOTO_API, + }); + const url = new URL(req.url); + + const sessionId = url.searchParams.get('sessionId'); + const cedula = url.searchParams.get('cedula'); + + const SessionId = sessionId as string; + + const client = await getRekognitionClient(req); + const response = await client.getFaceLivenessSessionResults({ + SessionId, + }); + + let isLive = false; + const confidence = response.Confidence; + + // Threshold for face liveness + if (confidence && confidence > 85) { + logger.info(`High confidence (${confidence}%) for citizen ${cedula}`); + isLive = true; + } else { + logger.warn(`Low confidence (${confidence}%) for citizen ${cedula}`); + return NextResponse.json({ + message: LIVENESS_LOW_CONFIDENCE_ERROR, + isLive: isLive, + status: 200, + }); + } + + if (isLive && response.ReferenceImage && response.ReferenceImage.Bytes) { + const { data } = await http.get(`/${cedula}/photo`, { + params: { + 'api-key': process.env.JCE_PHOTO_API_KEY, + }, + responseType: 'arraybuffer', + }); + + const buffer1 = Buffer.from(response.ReferenceImage.Bytes); + const buffer2 = Buffer.from(data, 'base64'); + const params = { + SourceImage: { + Bytes: buffer1, + }, + TargetImage: { + Bytes: buffer2, + }, + // Threshold for face match + SimilarityThreshold: 95, + }; + + try { + const response = await client.compareFaces(params); + if (response.FaceMatches && response.FaceMatches.length) { + const similarity = response.FaceMatches[0].Similarity; + logger.info(`High similarity (${similarity}%) for citizen ${cedula}`); + return NextResponse.json({ + isMatch: true, + status: 200, + }); + } else { + logger.warn(`Low similarity for citizen ${cedula}`); + return NextResponse.json({ + message: LIVENESS_NO_MATCH_ERROR, + isMatch: false, + status: 200, + }); + } + } catch (error) { + logger.error(error); + return NextResponse.json({ + message: LIVENESS_NO_MATCH_ERROR, + isMatch: false, + status: 500, + }); + } + } +} + +export async function POST( + req: NextRequest, + { params }: { params: { sessionId: string } }, + res: NextResponse, +): Promise { + const client = await getRekognitionClient(req); + + const response = await client.createFaceLivenessSession({ + // TODO: Create a unique token for each request, and reuse on retry + // ClientRequestToken: req.cookies.token, + }); + return NextResponse.json({ + sessionId: response.SessionId, + status: 200, + }); +} diff --git a/src/pages/api/citizens/[cedula].ts b/src/app/api/citizens/[cedula]/route.ts similarity index 70% rename from src/pages/api/citizens/[cedula].ts rename to src/app/api/citizens/[cedula]/route.ts index 054e9ff2..8fbaf7ca 100644 --- a/src/pages/api/citizens/[cedula].ts +++ b/src/app/api/citizens/[cedula]/route.ts @@ -1,35 +1,26 @@ -import { NextApiRequest, NextApiResponse } from 'next/types'; +import { NextRequest, NextResponse } from 'next/server'; import axios from 'axios'; import { CitizensBasicInformationResponse, CitizensBirthInformationResponse, CitizensTokenResponse, -} from '../types'; - -export default async function handler( - req: NextApiRequest, - res: NextApiResponse<{ - id: string; - name?: string; - names?: string; - firstSurname?: string; - secondSurname?: string; - gender?: string; - birthDate?: string; - } | void>, -): Promise { - const { token } = req.cookies; - - if (token !== process.env.SITE_COOKIE_KEY) { - return res.status(401).send(); - } +} from '../../types'; +import { CitizensDataFlow } from '../../types/citizens.type'; +export async function GET( + req: NextRequest, + { params }: { params: { cedula: string } }, + res: NextResponse, +): Promise { const http = axios.create({ baseURL: process.env.CEDULA_API, }); + const url = new URL(req.url); - const { cedula, validated } = req.query; + const { cedula } = params; + const validatedQueryParam = url.searchParams.get('validated'); + const validated = validatedQueryParam && validatedQueryParam === 'true'; const { data: citizensToken } = await http.post( `${process.env.CEDULA_TOKEN_API}`, @@ -70,7 +61,7 @@ export default async function handler( let { birthDate } = citizensBirthData.payload; birthDate = birthDate.split('T')[0]; - return res.status(200).json({ + return NextResponse.json({ names, id, firstSurname, @@ -80,5 +71,8 @@ export default async function handler( }); } - return res.status(200).json({ name, id }); + return NextResponse.json({ + name, + id, + }); } diff --git a/src/app/api/iam/[cedula]/route.ts b/src/app/api/iam/[cedula]/route.ts new file mode 100644 index 00000000..c8dfc582 --- /dev/null +++ b/src/app/api/iam/[cedula]/route.ts @@ -0,0 +1,31 @@ +import { NextRequest, NextResponse } from 'next/server'; +import axios from 'axios'; + +import { Identity } from '../../types'; + +export const dynamicParams = true; + +export async function GET( + req: NextRequest, + { params }: { params: { cedula: string } }, +): Promise { + const http = axios.create({ + baseURL: process.env.NEXT_PUBLIC_ORY_SDK_URL, + headers: { + Authorization: 'Bearer ' + process.env.ORY_SDK_TOKEN, + }, + }); + + const cedula = params.cedula; + + const { data: identity } = await http.get( + `/admin/identities?credentials_identifier=${cedula}`, + ); + + const exists = identity.length !== 0; + + return NextResponse.json({ + exists: exists, + status: 200, + }); +} diff --git a/src/app/api/pwned/[password]/route.ts b/src/app/api/pwned/[password]/route.ts new file mode 100644 index 00000000..fe947b8a --- /dev/null +++ b/src/app/api/pwned/[password]/route.ts @@ -0,0 +1,35 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { pwnedPassword } from 'hibp'; + +import { Crypto } from '@/helpers'; +import logger from '@/lib/logger'; + +export async function GET( + req: NextRequest, + params: { params: { password: string } }, + res: NextResponse, +): Promise { + const { password } = params.params; + + if (typeof password !== 'undefined') { + const passwordKey = Array.isArray(password) ? password[0] : password; + + try { + const data = await pwnedPassword(Crypto.decrypt(passwordKey)); + return NextResponse.json({ + data, + status: 200, + }); + } catch (error) { + logger.error('Decryption Error: ', error); + + return NextResponse.json({ + status: 500, + }); + } + } else { + return NextResponse.json({ + status: 400, + }); + } +} diff --git a/src/pages/api/recaptcha/assesments.ts b/src/app/api/recaptcha/route.ts similarity index 84% rename from src/pages/api/recaptcha/assesments.ts rename to src/app/api/recaptcha/route.ts index 0cb88713..32fccdd4 100644 --- a/src/pages/api/recaptcha/assesments.ts +++ b/src/app/api/recaptcha/route.ts @@ -1,6 +1,7 @@ import { ReCaptchaResponse } from '../types'; import axios, { AxiosResponse } from 'axios'; -import type { NextApiRequest, NextApiResponse } from 'next/types'; +import type { NextRequest } from 'next/server'; +import { NextResponse } from 'next/server'; import logger from '@/lib/logger'; @@ -27,10 +28,7 @@ const verifyRecaptcha = async ( return response.data; }; -export default async function handler( - req: NextApiRequest, - res: NextApiResponse, -) { +export async function POST(req: NextRequest, res: NextResponse) { if (!process.env.NEXT_PUBLIC_RECAPTCHA_SITE_KEY) { throw new Error( 'NEXT_PUBLIC_RECAPTCHA_SITE_KEY not found in environment variables', @@ -38,9 +36,10 @@ export default async function handler( } try { + const body = await req.json(); const recaptchaEvent: ReCaptchaEvent = { event: { - token: req.body.token, + token: body.token, siteKey: process.env.NEXT_PUBLIC_RECAPTCHA_SITE_KEY, expectedAction: 'form_submit', }, @@ -62,24 +61,24 @@ export default async function handler( // "hostname": string, // the hostname of the site where the reCAPTCHA was solved // "error-codes": [...] // optional // } - if (response.riskAnalysis && response.riskAnalysis.score >= 0.5) { - return res.status(200).json({ + if (response.riskAnalysis && response.riskAnalysis.score >= 0.3) { + return NextResponse.json({ isHuman: true, - status: 'Success', message: 'Thank you human', + status: 200, }); } else { - return res.status(200).json({ + return NextResponse.json({ isHuman: false, - status: 'Failure', message: 'Google ReCaptcha Failure', + status: 200, }); } } catch (error) { logger.error('Google ReCaptcha crashed', error); - res.status(500).json({ - status: 'Failure', - message: 'Something went wrong, please try again.', + NextResponse.json({ + error: 'Something went wrong, please try again.', + status: 500, }); } } diff --git a/src/pages/api/types/citizens.type.ts b/src/app/api/types/citizens.type.ts similarity index 76% rename from src/pages/api/types/citizens.type.ts rename to src/app/api/types/citizens.type.ts index 26aa3127..b381cbc9 100644 --- a/src/pages/api/types/citizens.type.ts +++ b/src/app/api/types/citizens.type.ts @@ -27,3 +27,13 @@ export type CitizensTokenResponse = { token_type: string; expires_in: number; }; + +export type CitizensDataFlow = { + id: string; + name?: string; + names?: string; + firstSurname?: string; + secondSurname?: string; + gender?: string; + birthDate?: string; +}; diff --git a/src/pages/api/types/iam.type.ts b/src/app/api/types/iam.type.ts similarity index 100% rename from src/pages/api/types/iam.type.ts rename to src/app/api/types/iam.type.ts diff --git a/src/pages/api/types/index.ts b/src/app/api/types/index.ts similarity index 100% rename from src/pages/api/types/index.ts rename to src/app/api/types/index.ts diff --git a/src/pages/api/types/recaptcha.type.ts b/src/app/api/types/recaptcha.type.ts similarity index 100% rename from src/pages/api/types/recaptcha.type.ts rename to src/app/api/types/recaptcha.type.ts diff --git a/src/app/layout.tsx b/src/app/layout.tsx new file mode 100644 index 00000000..6f8a88ed --- /dev/null +++ b/src/app/layout.tsx @@ -0,0 +1,45 @@ +import React from 'react'; +import type { Metadata } from 'next'; + +import { ReCaptchaProvider } from 'next-recaptcha-v3'; +import { GoogleTagManagerBody, GoogleTagManagerHead } from '@thgh/next-gtm'; + +import Layout from '../components/layout'; +import ThemeRegistry from '@/components/themes/ThemeRegistry'; +import SnackAlert from '@/components/elements/alert'; + +import '../../public/fonts/poppins_wght.css'; +import '@aws-amplify/ui-react/styles.css'; +import '@/styles/globals.css'; + +export const metadata: Metadata = { + title: 'Cuenta Única - Registro', + description: 'Plataforma de Registro para creación de tu Cuenta Única', + keywords: + 'Cuenta Única, Registro, Plataforma de Autenticación, Gobierno Dominicano, República Dominicana', + viewport: 'width=device-width, initial-scale=1, shrink-to-fit=no', +}; + +export default function RootLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + <> + + {GoogleTagManagerHead} + + + + + {children} + {GoogleTagManagerBody} + + + + + + + ); +} diff --git a/src/pages/index.tsx b/src/app/page.tsx similarity index 64% rename from src/pages/index.tsx rename to src/app/page.tsx index c0426b8f..719c3774 100644 --- a/src/pages/index.tsx +++ b/src/app/page.tsx @@ -1,5 +1,5 @@ import Register from './register'; -export default function Home() { +export default function Page() { return ; } diff --git a/src/pages/register/confirmation/index.tsx b/src/app/register/confirmation/page.tsx similarity index 93% rename from src/pages/register/confirmation/index.tsx rename to src/app/register/confirmation/page.tsx index 369efdba..e34c31f1 100644 --- a/src/pages/register/confirmation/index.tsx +++ b/src/app/register/confirmation/page.tsx @@ -1,8 +1,9 @@ +'use client'; + import MarkEmailReadOutlinedIcon from '@mui/icons-material/MarkEmailReadOutlined'; import { yupResolver } from '@hookform/resolvers/yup'; import { useForm } from 'react-hook-form'; -import { useRouter } from 'next/router'; -import { useState } from 'react'; +import { useRouter } from 'next/navigation'; import * as yup from 'yup'; import { GridContainer, GridItem } from '@/components/elements/grid'; @@ -11,7 +12,7 @@ import LandingChico from '../../../../public/assets/landingChico.svg'; import { CardAuth } from '@/components/elements/cardAuth'; import { ButtonApp } from '@/components/elements/button'; import { FormControlApp } from '@/components/form/input'; -import { InputApp } from '@/themes/form/input'; +import { InputApp } from '@/components/themes/form/input'; import { routes } from '@/constants/routes'; import { labels } from '@/constants/labels'; @@ -30,7 +31,9 @@ const schema = yup.object({ export default function Index() { const router = useRouter(); - const [dataItem] = useState({}); + const dataItem = { + cedula: '', + }; const { register, diff --git a/src/pages/register/index.tsx b/src/app/register/index.tsx similarity index 78% rename from src/pages/register/index.tsx rename to src/app/register/index.tsx index 2e96f0de..d38060c9 100644 --- a/src/pages/register/index.tsx +++ b/src/app/register/index.tsx @@ -1,9 +1,17 @@ +'use client'; + import BoxContentCenter from '@/components/elements/boxContentCenter'; import LandingChica2 from '../../../public/assets/landingChica.svg'; import { CardAuth } from '@/components/elements/cardAuth'; +import { setCookie } from '../../actions'; import StepperRegister from './stepper'; +import { useEffect } from 'react'; export default function Index() { + useEffect(() => { + setCookie(); + }, [setCookie]); + return ( component for navigation unless we have a specific requirement for using useRouter +import { useRouter } from 'next/navigation'; import Step from '@mui/material/Step'; import Box from '@mui/material/Box'; import * as React from 'react'; -import axios from 'axios'; +import { useState, Fragment } from 'react'; import { routes } from '@/constants/routes'; import Step1 from './step1'; @@ -22,25 +25,13 @@ const optionalLabels = [ 'Cuenta de usuario', ]; -export async function getServerSideProps() { - await axios.get(`/api/auth`); - - return { - props: { data: {} }, - }; -} - export default function StepperRegister() { const router = useRouter(); const theme = useTheme(); const matches = useMediaQuery(theme.breakpoints.up('sm')); - const [activeStep, setActiveStep] = React.useState(0); - const [skipped, setSkipped] = React.useState(new Set()); - const [infoCedula, setInfoCedula] = React.useState({}); - - React.useEffect(() => { - axios.get(`/api/auth`).then().catch(); - }, []); + const [activeStep, setActiveStep] = useState(0); + const [skipped, setSkipped] = useState(new Set()); + const [infoCedula, setInfoCedula] = useState({}); const handleNext = () => { if (activeStep === steps.length - 1) { @@ -101,7 +92,7 @@ export default function StepperRegister() { ))} {activeStep === steps.length ? ( - + All steps completed - you're finished @@ -109,7 +100,7 @@ export default function StepperRegister() { - + ) : (
{getStepComponent()}
)} diff --git a/src/pages/register/stepper/step1.tsx b/src/app/register/stepper/step1.tsx similarity index 92% rename from src/pages/register/stepper/step1.tsx rename to src/app/register/stepper/step1.tsx index 75df1ae9..074814da 100644 --- a/src/pages/register/stepper/step1.tsx +++ b/src/app/register/stepper/step1.tsx @@ -18,7 +18,7 @@ import { import { GridContainer, GridItem } from '@/components/elements/grid'; import LoadingBackdrop from '@/components/elements/loadingBackdrop'; import { TextBodyTiny } from '@/components/elements/typography'; -import { useSnackbar } from '@/components/elements/alert'; +import { useSnackAlert } from '@/components/elements/alert'; import { ButtonApp } from '@/components/elements/button'; import { CedulaInput, CustomProps } from '../../../common/interfaces'; import { cedulaSchema } from '../../../common/yup-schemas'; @@ -42,8 +42,7 @@ const TextMaskCustom = forwardRef( ); export default function Step1({ setInfoCedula, handleNext }: any) { - const [valueCedula, setValueCedula] = useState(''); - const { AlertError, AlertWarning } = useSnackbar(); + const { AlertError, AlertWarning } = useSnackAlert(); const [loading, setLoading] = useState(false); const { executeRecaptcha } = useReCaptcha(); @@ -51,16 +50,18 @@ export default function Step1({ setInfoCedula, handleNext }: any) { handleSubmit: handleFormSubmit, formState: { errors }, setValue, + watch, } = useForm({ reValidateMode: 'onSubmit', resolver: yupResolver(cedulaSchema), }); + const valueCedula = watch('cedula', ''); const onCedulaChangeHandler = ( event: React.ChangeEvent, ) => { - setValue('cedula', event.target.value.replace(/-/g, '')); - setValueCedula(event.target.value); + const valueWithoutHyphens = event.target.value.replace(/-/g, ''); + setValue('cedula', valueWithoutHyphens); }; const handleSubmit = useCallback( @@ -89,12 +90,9 @@ export default function Step1({ setInfoCedula, handleNext }: any) { try { const { data: { isHuman }, - } = await axios.post<{ isHuman: boolean }>( - '/api/recaptcha/assesments', - { - token, - }, - ); + } = await axios.post<{ isHuman: boolean }>('/api/recaptcha', { + token, + }); if (!isHuman) { return AlertError(RECAPTCHA_VALIDATION_ERROR); @@ -152,6 +150,9 @@ export default function Step1({ setInfoCedula, handleNext }: any) { autoComplete="off" error={Boolean(errors.cedula)} helperText={errors?.cedula?.message} + inputProps={{ + inputMode: 'numeric', + }} InputProps={{ inputComponent: TextMaskCustom as any, }} diff --git a/src/pages/register/stepper/step2.tsx b/src/app/register/stepper/step2.tsx similarity index 98% rename from src/pages/register/stepper/step2.tsx rename to src/app/register/stepper/step2.tsx index 577f4f25..adcf9fd4 100644 --- a/src/pages/register/stepper/step2.tsx +++ b/src/app/register/stepper/step2.tsx @@ -18,7 +18,7 @@ import { Step2Props, TermsAndConditionsInput, } from '../../../common/interfaces'; -import { useSnackbar } from '@/components/elements/alert'; +import { useSnackAlert } from '@/components/elements/alert'; import Step2Modal from './step2Modal'; import { NON_ACCEPTED_TERMS_AND_CONDS_ERROR } from '@/constants'; @@ -36,7 +36,7 @@ export default function Step2({ register, formState: { errors }, } = useForm(); - const { AlertWarning } = useSnackbar(); + const { AlertWarning } = useSnackAlert(); const onSubmit = (data: TermsAndConditionsInput) => { if (!data.acceptTermAndConditions) { diff --git a/src/pages/register/stepper/step2Modal.tsx b/src/app/register/stepper/step2Modal.tsx similarity index 97% rename from src/pages/register/stepper/step2Modal.tsx rename to src/app/register/stepper/step2Modal.tsx index 5ba52245..de5536ce 100644 --- a/src/pages/register/stepper/step2Modal.tsx +++ b/src/app/register/stepper/step2Modal.tsx @@ -11,7 +11,7 @@ import Image from 'next/image'; import { LivenessQuickStartReact } from '@/components/biometric/face-liveness-detector'; import { ButtonApp } from '@/components/elements/button'; import Logo from '../../../../public/assets/logo.svg'; -import { theme } from '@/themes'; +import theme from '@/components/themes/theme'; const Transition = forwardRef(function Transition( props: TransitionProps & { diff --git a/src/pages/register/stepper/step3.tsx b/src/app/register/stepper/step3.tsx similarity index 87% rename from src/pages/register/stepper/step3.tsx rename to src/app/register/stepper/step3.tsx index 36e2819a..3f377f98 100644 --- a/src/pages/register/stepper/step3.tsx +++ b/src/app/register/stepper/step3.tsx @@ -1,3 +1,4 @@ +'use client'; import { Alert, Box, @@ -14,7 +15,7 @@ import Visibility from '@mui/icons-material/Visibility'; import { yupResolver } from '@hookform/resolvers/yup'; import { useEffect, useState } from 'react'; import { useForm } from 'react-hook-form'; -import { useRouter } from 'next/router'; +import { useSearchParams } from 'next/navigation'; import axios from 'axios'; import { @@ -23,39 +24,34 @@ import { VALIDATE_PASSWORD_ERROR, } from '../../../constants'; import { GridContainer, GridItem } from '@/components/elements/grid'; -import LoadingBackdrop from '@/components/elements/loadingBackdrop'; import PasswordLevel, { calculatePasswordStrength, } from '@/components/elements/passwordLevel'; import { CitizenCompleteData, Step3Form } from '../../../common/interfaces'; -import { useSnackbar } from '@/components/elements/alert'; +import { useSnackAlert } from '@/components/elements/alert'; import { ButtonApp } from '@/components/elements/button'; import { step3Schema } from '../../../common/yup-schemas'; import { Crypto } from '@/helpers'; -import { orySdk } from '@/sdk'; +import { ory } from '@/lib/ory'; +import { isUiNodeInputAttributes } from '@ory/integrations/ui'; export default function Step3({ handleNext, infoCedula }: any) { - const router = useRouter(); - const [flow, setFlow] = useState(); // TODO: validate this flowId on account verification - // const { flow: flowId, return_to: returnTo } = router.query; - - const { return_to: returnTo } = router.query; - const [loadingValidatingPassword, setLoadingValidatingPassword] = - useState(false); - const [loading, setLoading] = useState(false); const [passwordLevel, setPasswordLevel] = useState({}); const [passwordString, setPasswordString] = useState(''); const [isPwned, setIsPwned] = useState(false); - const { AlertWarning, AlertError } = useSnackbar(); + const { AlertWarning, AlertError } = useSnackAlert(); const [showPassword, setShowPassword] = useState(false); const [showPasswordConfirm, setShowPasswordConfirm] = useState(false); + const searchParams = useSearchParams(); + let returnTo = searchParams?.get('return_to'); + useEffect(() => { - const asyncEffect = async () => { + const fetchFlow = async () => { try { - const { data: flow } = await orySdk.createBrowserRegistrationFlow({ + const { data: flow } = await ory.createBrowserRegistrationFlow({ returnTo: returnTo ? String(returnTo) : undefined, }); @@ -66,10 +62,9 @@ export default function Step3({ handleNext, infoCedula }: any) { } }; - asyncEffect(); - + fetchFlow(); // eslint-disable-next-line - }, []); + }, [returnTo]); const { register, @@ -99,8 +94,6 @@ export default function Step3({ handleNext, infoCedula }: any) { return; } - setLoadingValidatingPassword(true); - const password = Crypto.encrypt(form.password); try { @@ -112,34 +105,36 @@ export default function Step3({ handleNext, infoCedula }: any) { AlertError(VALIDATE_PASSWORD_ERROR); return; - } finally { - setLoadingValidatingPassword(false); } try { - setLoading(true); - const { data: citizen } = await axios.get( `/api/citizens/${infoCedula.id}?validated=true`, ); - const node: any = flow?.ui.nodes.find( - (n: any) => n.attributes['name'] === 'csrf_token', - ); - const csrf_token = node?.attributes.value as string; - const last = `${citizen.firstSurname} ${citizen.secondSurname}`; - const method = 'password'; + let csrfToken = ''; + if (flow && flow.ui && Array.isArray(flow.ui.nodes)) { + const csrfNode = flow.ui.nodes.find( + (node) => + isUiNodeInputAttributes(node.attributes) && + node.attributes.name === 'csrf_token', + ); + + if (csrfNode) { + csrfToken = (csrfNode.attributes as any).value; + } + } const updateRegistrationFlowBody: UpdateRegistrationFlowBody = { - csrf_token, - method, + csrf_token: csrfToken, + method: 'password', password: form.password, traits: { email: form.email, username: citizen.id, name: { first: citizen.names, - last, + last: `${citizen.firstSurname} ${citizen.secondSurname}`, }, birthdate: citizen.birthDate, gender: citizen.gender, @@ -148,7 +143,7 @@ export default function Step3({ handleNext, infoCedula }: any) { const { data: { continue_with }, - } = await orySdk.updateRegistrationFlow({ + } = await ory.updateRegistrationFlow({ flow: String(flow?.id), updateRegistrationFlowBody, }); @@ -209,19 +204,12 @@ export default function Step3({ handleNext, infoCedula }: any) { AlertError(CREATE_IDENTITY_ERROR); return; - } finally { - setLoading(false); } }; // TODO: Use this Password UI approach https://stackblitz.com/edit/material-password-strength?file=Icons.js return ( <> - {loadingValidatingPassword && ( - - )} - {loading && } - (true); const [error, setError] = useState(null); const [sessionId, setSessionId] = useState(null); - const { AlertError } = useSnackbar(); + const { AlertError } = useSnackAlert(); - const fetchCreateLiveness = async () => { + const fetchCreateLiveness: () => Promise = async () => { const response = await fetch(`/api/biometric`, { method: 'POST' }); + await new Promise((r) => setTimeout(r, 2000)); const { sessionId } = await response.json(); setSessionId(sessionId); @@ -29,7 +37,7 @@ export function LivenessQuickStartReact({ handleNextForm, cedula }: any) { fetchCreateLiveness(); }; - const handleAnalysisComplete = async () => { + const handleAnalysisComplete: () => Promise = async () => { const response = await fetch( `/api/biometric?sessionId=${sessionId}&cedula=${id}`, ); @@ -63,30 +71,30 @@ export function LivenessQuickStartReact({ handleNextForm, cedula }: any) { }, [error]); return ( - <> -
- - {loading ? ( + + {loading ? ( +
- ) : ( - sessionId && ( - { - console.error({ - state: livenessError.state, - error: livenessError.error, - }); - }} - onAnalysisComplete={handleAnalysisComplete} - disableInstructionScreen={false} - displayText={displayText} - /> - ) - )} - - +
+ ) : sessionId ? ( + + ) : null} +
); } diff --git a/src/components/elements/alert/index.tsx b/src/components/elements/alert/index.tsx index 50131f1d..96a3a16d 100644 --- a/src/components/elements/alert/index.tsx +++ b/src/components/elements/alert/index.tsx @@ -1,74 +1,87 @@ +'use client'; + import React, { createContext, useContext, useState } from 'react'; import Snackbar from '@mui/material/Snackbar'; import MuiAlert, { AlertProps } from '@mui/material/Alert'; -interface SnackbarContextProps { - openSnackbar: (message: string, severity: AlertProps['severity']) => void; +// Snackbar state and methods interface +interface SnackbarState { + vertical: 'top' | 'bottom'; + horizontal: 'left' | 'center' | 'right'; + open: boolean; + duration: number; + content: string; + severity: AlertProps['severity']; } -// Create context with default undefined value -const SnackbarContext = createContext( - undefined, -); +interface SnackAlertMethods { + AlertError: (text?: string) => void; + AlertWarning: (text: string) => void; + AlertSuccess: (text: string) => void; +} -interface SnackbarProviderProps { - children?: React.ReactNode; +interface SnackAlertProps { + children: React.ReactNode; } -export const SnackbarProvider = ({ children }: SnackbarProviderProps) => { - const [snackbarConfig, setSnackbarConfig] = useState({ +// Create context for the alert methods +const SnackAlertContext = createContext(null); + +export const useSnackAlert = () => { + const context = useContext(SnackAlertContext); + if (!context) { + throw new Error('useSnackAlert must be used within SnackAlert'); + } + return context; +}; + +const SnackAlert: React.FC = ({ children }) => { + const defaultSnackbarState: SnackbarState = { + vertical: 'bottom', + horizontal: 'left', open: false, - message: '', + duration: 6000, + content: '', severity: 'success' as AlertProps['severity'], - }); - - // Open snackbar - const openSnackbar = (message: string, severity: AlertProps['severity']) => { - setSnackbarConfig({ open: true, message, severity }); }; - // Close snackbar - const closeSnackbar = () => { - setSnackbarConfig((prevState) => ({ ...prevState, open: false })); - }; + const [snackbar, setSnackbar] = useState(defaultSnackbarState); + + const handleClose = () => setSnackbar({ ...snackbar, open: false }); + + const handleOpen = (data: Partial) => + setSnackbar({ ...snackbar, ...data, open: true }); + + const AlertError = ( + text: string = 'Ocurrió un error al procesar la solicitud', + ) => handleOpen({ content: text, severity: 'error' }); + + const AlertWarning = (text: string) => + handleOpen({ content: text, severity: 'warning' }); + + const AlertSuccess = (text: string = 'Proceso realizado correctamente') => + handleOpen({ content: text, severity: 'success' }); + + const { vertical, horizontal, open, severity, content, duration } = snackbar; return ( - + + {children} - - {snackbarConfig.message} + + {content} - {children} - + ); }; -export const useSnackbar = () => { - const context = useContext(SnackbarContext); - - if (!context) { - throw new Error('useSnackbar must be used within a SnackbarProvider'); - } - - const { openSnackbar } = context; - - return { - AlertError: (text?: string) => - openSnackbar( - text || 'Ocurrió un error al procesar la solicitud', - 'error', - ), - AlertWarning: (text: string) => openSnackbar(text, 'warning'), - AlertSuccess: (text: string) => - openSnackbar(text || 'Proceso realizado correctamente', 'success'), - }; -}; +export default SnackAlert; diff --git a/src/components/elements/loading/index.tsx b/src/components/elements/loading/index.tsx deleted file mode 100644 index a4796eeb..00000000 --- a/src/components/elements/loading/index.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import CircularProgress from '@mui/material/CircularProgress'; -import Box from '@mui/material/Box'; - -export const LoadingProgress = () => { - return ( - - - - ); -}; diff --git a/src/components/elements/loadingBackdrop/index.tsx b/src/components/elements/loadingBackdrop/index.tsx index db3bbe9a..22ebc9bb 100644 --- a/src/components/elements/loadingBackdrop/index.tsx +++ b/src/components/elements/loadingBackdrop/index.tsx @@ -1,4 +1,4 @@ -import * as React from 'react'; +import { useState } from 'react'; import Backdrop from '@mui/material/Backdrop'; import CircularProgress from '@mui/material/CircularProgress'; import { Typography } from '@mui/material'; @@ -8,7 +8,7 @@ interface IProps { } export default function LoadingBackdrop({ text }: IProps) { - const [open] = React.useState(true); + const [open] = useState(true); return ( ; + /** By default from 'import { CacheProvider } from "@emotion/react"' */ + CacheProvider?: (props: { + value: EmotionCache; + children: React.ReactNode; + }) => React.JSX.Element | null; + children: React.ReactNode; +}; + +// Adapted from https://github.com/garronej/tss-react/blob/main/src/next/appDir.tsx +export default function NextAppDirEmotionCacheProvider( + props: NextAppDirEmotionCacheProviderProps, +) { + const { options, CacheProvider = DefaultCacheProvider, children } = props; + + const [registry] = React.useState(() => { + const cache = createCache(options); + cache.compat = true; + const prevInsert = cache.insert; + let inserted: { name: string; isGlobal: boolean }[] = []; + cache.insert = (...args) => { + const [selector, serialized] = args; + if (cache.inserted[serialized.name] === undefined) { + inserted.push({ + name: serialized.name, + isGlobal: !selector, + }); + } + return prevInsert(...args); + }; + const flush = () => { + const prevInserted = inserted; + inserted = []; + return prevInserted; + }; + return { cache, flush }; + }); + + useServerInsertedHTML(() => { + const inserted = registry.flush(); + if (inserted.length === 0) { + return null; + } + let styles = ''; + let dataEmotionAttribute = registry.cache.key; + + const globals: { + name: string; + style: string; + }[] = []; + + inserted.forEach(({ name, isGlobal }) => { + const style = registry.cache.inserted[name]; + + if (typeof style !== 'boolean') { + if (isGlobal) { + globals.push({ name, style }); + } else { + styles += style; + dataEmotionAttribute += ` ${name}`; + } + } + }); + + return ( + + {globals.map(({ name, style }) => ( +