diff --git a/.dockerignore b/.dockerignore index 7a38b3f..125256f 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,4 +1,33 @@ -node_modules/ -.github/ -.idea/ -.vscode/ \ No newline at end of file +# Node modules +node_modules +npm-debug.log + +# Build artifacts +.next +out + +# Local environment files +.env + +# Logs +logs +*.log + +# IDE and editor files +.vscode +.idea +*.swp +*.swo + +# Operating system files +.DS_Store +Thumbs.db + +# Git files +.git +.gitignore + +# Other files you may want to ignore +coverage +test-results +*.tgz diff --git a/.env.template b/.env.template new file mode 100644 index 0000000..34c3e05 --- /dev/null +++ b/.env.template @@ -0,0 +1,5 @@ +ORY_SDK_URL=http://localhost:4433 +HYDRA_ADMIN_URL=http://localhost:4445 +HYDRA_PUBLIC_URL=http://localhost:4444 +BASE_PATH=/kratos-ui +NEXT_PUBLIC_KRATOS_PUBLIC_URL=http://localhost:3000/kratos-ui \ No newline at end of file diff --git a/.gitignore b/.gitignore index 509ec38..de427c6 100644 --- a/.gitignore +++ b/.gitignore @@ -38,4 +38,5 @@ dist/ cypress/videos cypress/screenshots -.idea \ No newline at end of file +.idea +.env \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index acb5693..01c16ae 100644 --- a/Dockerfile +++ b/Dockerfile @@ -15,7 +15,13 @@ RUN npm install COPY . . -RUN npm run build +RUN npm run build + +# Copy the env substitution script and ensure it is executable +COPY docker/30-env-subst.sh /docker-entrypoint.d/30-env-subst.sh +RUN chmod +x /docker-entrypoint.d/30-env-subst.sh + +ENTRYPOINT ["/bin/sh", "-c", "/docker-entrypoint.d/30-env-subst.sh && exec \"$@\"", "--"] EXPOSE 3000 diff --git a/cypress.json b/cypress.json index 17ef242..91aab53 100644 --- a/cypress.json +++ b/cypress.json @@ -1,3 +1,3 @@ { - "baseUrl": "http://localhost:3000" + "baseUrl": "http://localhost:3000/kratos-ui" } diff --git a/docker/30-env-subst.sh b/docker/30-env-subst.sh new file mode 100644 index 0000000..0133a1f --- /dev/null +++ b/docker/30-env-subst.sh @@ -0,0 +1,19 @@ +#!/bin/sh + +# Log that the script is running +echo "Running docker-entrypoint.sh." + +# Replace placeholder with actual environment variable values +if [ -n "$BASE_PATH" ]; then + echo "Replacing BASE_PATH in env.template.js with ${BASE_PATH}" + sed -i "s|/__BASE_PATH__|${BASE_PATH}|g" /app/env.js +else + echo "BASE_PATH is not set." +fi + +# Log the contents of env.template.js after modification +echo "Contents of env.template.js after replacement:" +cat /app/env.js + +# Start the application +echo "Starting the application..." \ No newline at end of file diff --git a/env.js b/env.js new file mode 100644 index 0000000..1977647 --- /dev/null +++ b/env.js @@ -0,0 +1,6 @@ +module.exports = { + basePath: "/kratos-ui", + restSourceBackendEndpoint: "", + restSourceFrontendEndpoint: "", + hydraPublicUrl: "", +} diff --git a/next.config.js b/next.config.js index 8b61df4..b9f1dd7 100644 --- a/next.config.js +++ b/next.config.js @@ -1,4 +1,21 @@ -/** @type {import('next').NextConfig} */ +const envConfig = require("./env.js") // Load the generated config + module.exports = { reactStrictMode: true, + + // Set basePath and assetPrefix dynamically + basePath: envConfig.basePath || "", + assetPrefix: `${envConfig.basePath}/` || "", + restSourceBackendEndpoint: `${envConfig.restSourceBackendEndpoint}/` || "", + restSourceFrontendEndpoint: `${envConfig.restSourceFrontendEndpoint}/` || "", + hydraPublicUrl: `${envConfig.hydraPublicUrl}/` || "", + + publicRuntimeConfig: { + basePath: envConfig.basePath || "", + frontEndClientId: "SEP", + frontEndClientSecret: "secret", + restSourceBackendEndpoint: envConfig.restSourceBackendEndpoint, + restSourceFrontendEndpoint: envConfig.restSourceFrontendEndpoint, + hydraPublicUrl: envConfig.hydraPublicUrl, + }, } diff --git a/package-lock.json b/package-lock.json index 475b492..89a10ed 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,15 +15,18 @@ "next": "12.1.6", "react": "17.0.2", "react-dom": "17.0.2", + "react-qr-code": "^2.0.15", "react-toastify": "^8.0.3", "styled-components": "^5.3.1", - "typescript": "^4.4.2" + "typescript": "^4.4.2", + "uuid": "^10.0.0" }, "devDependencies": { "@trivago/prettier-plugin-sort-imports": "^3.1.0", "@types/react": "~17.0.19", "@types/request": "^2.48.7", "@types/styled-components": "^5.1.13", + "@types/uuid": "^10.0.0", "cypress": "^8.7.0", "eslint": "7.32.0", "eslint-config-next": "12.0.3", @@ -750,6 +753,15 @@ "node": ">= 0.12" } }, + "node_modules/@cypress/request/node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "dev": true, + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/@cypress/xvfb": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/@cypress/xvfb/-/xvfb-1.2.4.tgz", @@ -1602,6 +1614,12 @@ "integrity": "sha512-Y0K95ThC3esLEYD6ZuqNek29lNX2EM1qxV8y2FTLUB0ff5wWrk7az+mLrnNFUnaXcgKye22+sFBRXOgpPILZNg==", "dev": true }, + "node_modules/@types/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==", + "dev": true + }, "node_modules/@types/yauzl": { "version": "2.9.2", "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.9.2.tgz", @@ -7226,21 +7244,19 @@ } }, "node_modules/prop-types": { - "version": "15.7.2", - "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.7.2.tgz", - "integrity": "sha512-8QQikdH7//R2vurIJSutZ1smHYTcLpRWEOlHnzcWHmBYrOGUysKwSsrC89BCiFj3CbrfJ/nXFdJepOVrY1GCHQ==", - "dev": true, + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", - "react-is": "^16.8.1" + "react-is": "^16.13.1" } }, "node_modules/prop-types/node_modules/react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", - "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "dev": true + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" }, "node_modules/proxy-from-env": { "version": "1.0.0", @@ -7271,6 +7287,11 @@ "node": ">=6" } }, + "node_modules/qr.js": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/qr.js/-/qr.js-0.0.0.tgz", + "integrity": "sha512-c4iYnWb+k2E+vYpRimHqSu575b1/wKl4XFeJGpFmrJQz5I88v9aY2czh7s0w36srfCM1sXgC/xpoJz5dJfq+OQ==" + }, "node_modules/qs": { "version": "6.5.3", "resolved": "https://registry.npmjs.org/qs/-/qs-6.5.3.tgz", @@ -7346,6 +7367,18 @@ "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "peer": true }, + "node_modules/react-qr-code": { + "version": "2.0.15", + "resolved": "https://registry.npmjs.org/react-qr-code/-/react-qr-code-2.0.15.tgz", + "integrity": "sha512-MkZcjEXqVKqXEIMVE0mbcGgDpkfSdd8zhuzXEl9QzYeNcw8Hq2oVIzDLWuZN2PQBwM5PWjc2S31K8Q1UbcFMfw==", + "dependencies": { + "prop-types": "^15.8.1", + "qr.js": "0.0.0" + }, + "peerDependencies": { + "react": "*" + } + }, "node_modules/react-toastify": { "version": "8.1.0", "resolved": "https://registry.npmjs.org/react-toastify/-/react-toastify-8.1.0.tgz", @@ -8317,10 +8350,13 @@ "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=" }, "node_modules/uuid": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", - "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", - "dev": true, + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], "bin": { "uuid": "dist/bin/uuid" } @@ -9029,6 +9065,12 @@ "combined-stream": "^1.0.6", "mime-types": "^2.1.12" } + }, + "uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "dev": true } } }, @@ -9584,6 +9626,12 @@ "integrity": "sha512-Y0K95ThC3esLEYD6ZuqNek29lNX2EM1qxV8y2FTLUB0ff5wWrk7az+mLrnNFUnaXcgKye22+sFBRXOgpPILZNg==", "dev": true }, + "@types/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==", + "dev": true + }, "@types/yauzl": { "version": "2.9.2", "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.9.2.tgz", @@ -13649,21 +13697,19 @@ "dev": true }, "prop-types": { - "version": "15.7.2", - "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.7.2.tgz", - "integrity": "sha512-8QQikdH7//R2vurIJSutZ1smHYTcLpRWEOlHnzcWHmBYrOGUysKwSsrC89BCiFj3CbrfJ/nXFdJepOVrY1GCHQ==", - "dev": true, + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", "requires": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", - "react-is": "^16.8.1" + "react-is": "^16.13.1" }, "dependencies": { "react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", - "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "dev": true + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" } } }, @@ -13693,6 +13739,11 @@ "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz", "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==" }, + "qr.js": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/qr.js/-/qr.js-0.0.0.tgz", + "integrity": "sha512-c4iYnWb+k2E+vYpRimHqSu575b1/wKl4XFeJGpFmrJQz5I88v9aY2czh7s0w36srfCM1sXgC/xpoJz5dJfq+OQ==" + }, "qs": { "version": "6.5.3", "resolved": "https://registry.npmjs.org/qs/-/qs-6.5.3.tgz", @@ -13741,6 +13792,15 @@ "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "peer": true }, + "react-qr-code": { + "version": "2.0.15", + "resolved": "https://registry.npmjs.org/react-qr-code/-/react-qr-code-2.0.15.tgz", + "integrity": "sha512-MkZcjEXqVKqXEIMVE0mbcGgDpkfSdd8zhuzXEl9QzYeNcw8Hq2oVIzDLWuZN2PQBwM5PWjc2S31K8Q1UbcFMfw==", + "requires": { + "prop-types": "^15.8.1", + "qr.js": "0.0.0" + } + }, "react-toastify": { "version": "8.1.0", "resolved": "https://registry.npmjs.org/react-toastify/-/react-toastify-8.1.0.tgz", @@ -14463,10 +14523,9 @@ "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=" }, "uuid": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", - "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", - "dev": true + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==" }, "v8-compile-cache": { "version": "2.3.0", diff --git a/package.json b/package.json index 9ff242c..2f9ad4d 100644 --- a/package.json +++ b/package.json @@ -27,9 +27,11 @@ "next": "12.1.6", "react": "17.0.2", "react-dom": "17.0.2", + "react-qr-code": "^2.0.15", "react-toastify": "^8.0.3", "styled-components": "^5.3.1", - "typescript": "^4.4.2" + "typescript": "^4.4.2", + "uuid": "^10.0.0" }, "peerDependencies": { "next": "12.1.6", @@ -42,6 +44,7 @@ "@types/react": "~17.0.19", "@types/request": "^2.48.7", "@types/styled-components": "^5.1.13", + "@types/uuid": "^10.0.0", "cypress": "^8.7.0", "eslint": "7.32.0", "eslint-config-next": "12.0.3", diff --git a/pages/_app.tsx b/pages/_app.tsx index d0dc0ac..d4bb9e3 100644 --- a/pages/_app.tsx +++ b/pages/_app.tsx @@ -1,6 +1,7 @@ import "../styles/globals.css" import { theme, globalStyles, ThemeProps } from "@ory/themes" import type { AppProps } from "next/app" +import Head from "next/head" import { ToastContainer } from "react-toastify" import "react-toastify/dist/ReactToastify.css" import { ThemeProvider } from "styled-components" diff --git a/pages/api/consent.ts b/pages/api/consent.ts index 5632cf5..13c6ad7 100644 --- a/pages/api/consent.ts +++ b/pages/api/consent.ts @@ -1,15 +1,7 @@ -import { Configuration, OAuth2Api } from "@ory/client" +import axios from "axios" import { NextApiRequest, NextApiResponse } from "next" -const hydra = new OAuth2Api( - new Configuration({ - basePath: process.env.HYDRA_ADMIN_URL, - baseOptions: { - "X-Forwarded-Proto": "https", - withCredentials: true, - }, - }), -) +const baseURL = process.env.HYDRA_ADMIN_URL // Helper function to extract session data const extractSession = (identity: any, grantScope: string[]) => { @@ -33,40 +25,49 @@ export default async (req: NextApiRequest, res: NextApiResponse) => { try { if (req.method === "GET") { const { consent_challenge } = req.query - const response = await hydra.getOAuth2ConsentRequest({ - consentChallenge: String(consent_challenge), - }) + const response = await axios.get( + `${baseURL}/oauth2/auth/requests/consent`, + { + params: { + consent_challenge: 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 { data: body } = await axios.get( + `${baseURL}/oauth2/auth/requests/consent`, + { + params: { consent_challenge: consentChallenge }, + }, + ) + const session = extractSession(identity, grantScope) - const acceptResponse = await hydra.acceptOAuth2ConsentRequest({ - consentChallenge, - acceptOAuth2ConsentRequest: { - grant_scope: grantScope, + const acceptResponse = await axios.put( + `${baseURL}/oauth2/auth/requests/consent/accept?consent_challenge=${consentChallenge}`, + { + grant_scope: session.access_token.scope, 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: { + const rejectResponse = await axios.put( + `${baseURL}/oauth2/auth/requests/consent/${consentChallenge}/reject`, + { error: "access_denied", error_description: "The resource owner denied the request", }, - }) + ) return res .status(200) diff --git a/pages/api/login.ts b/pages/api/login.ts new file mode 100644 index 0000000..bf97243 --- /dev/null +++ b/pages/api/login.ts @@ -0,0 +1,35 @@ +import axios from "axios" +import type { NextApiRequest, NextApiResponse } from "next" + +const baseURL = process.env.HYDRA_ADMIN_URL + +export default async function handler( + req: NextApiRequest, + res: NextApiResponse, +) { + if (req.method === "POST") { + const { loginChallenge, subject, remember } = req.body + + try { + const response = await axios.put( + `${baseURL}/oauth2/auth/requests/login/accept?login_challenge=${loginChallenge}`, + { + subject, + remember, + }, + ) + + res.status(200).json({ redirect_to: response.data.redirect_to }) + } catch (error) { + console.error("Error in API handler:", error) + if (axios.isAxiosError(error) && error.response) { + res.status(error.response.status).json({ message: error.response.data }) + } else { + res.status(500).json({ message: "Internal Server Error" }) + } + } + } else { + res.setHeader("Allow", ["POST"]) + res.status(405).end(`Method ${req.method} Not Allowed`) + } +} diff --git a/pages/apps.tsx b/pages/apps.tsx new file mode 100644 index 0000000..9904096 --- /dev/null +++ b/pages/apps.tsx @@ -0,0 +1,118 @@ +import { SettingsFlow } from "@ory/client" +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 QRCode from "react-qr-code" +import getConfig from "next/config" + +import { ActionCard, CenterLink, Methods, CardTitle } from "../pkg" +import ory from "../pkg/sdk" + +const { publicRuntimeConfig } = getConfig() + +interface Props { + flow?: SettingsFlow + only?: Methods +} + +function AppLoginCard({ children }: Props & { children: ReactNode }) { + return ( + + {children} + + ) +} + +const Apps: NextPage = () => { + const router = useRouter() + const DefaultHydraUrl = publicRuntimeConfig.hydraPublicUrl + const { flow: flowId, return_to: returnTo } = router.query + const [traits, setTraits] = useState() + const [projects, setProjects] = useState([]) + + const handleNavigation = () => { + router.replace("/fitbit") + } + + useEffect(() => { + // If the router is not ready yet, or we already have a flow, do nothing. + if (!router.isReady) { + return + } + + // Otherwise we initialize it + ory.toSession().then(({ data }) => { + const traits = data?.identity?.traits + setTraits(traits) + setProjects(traits.projects) + }) + }, [flowId, router, router.isReady, returnTo]) + + return ( + <> + + App Login + + + + App Login + + + + + Go back + + + + ) +} + +interface QrFormProps { + projects: any[] + baseUrl: string + navigate: any +} + +const QrForm: React.FC = ({ projects, baseUrl, navigate }) => { + if (projects) { + return ( +
+ {projects.map((project) => ( +
+

{project.name}

+ +

Scan the QR code below with your app.

+ Login with Active App +
+
+
+
+ +

Click the button below to redirect to Fitbit.

+ +
+
+ ))} +
+ ) + } else { + return ( +
+ +
+ ) + } +} + +export default Apps diff --git a/pages/consent.tsx b/pages/consent.tsx index fa202ec..25f57dd 100644 --- a/pages/consent.tsx +++ b/pages/consent.tsx @@ -9,11 +9,15 @@ const Consent = () => { const [consent, setConsent] = useState(null) const [identity, setIdentity] = useState(null) const [csrfToken, setCsrfToken] = useState("") + const [isLoading, setIsLoading] = useState(false) + + const basePath = process.env.BASE_PATH || "/kratos-ui" useEffect(() => { const { consent_challenge } = router.query const fetchSessionAndConsent = async () => { + setIsLoading(true) try { const sessionResponse = await ory.toSession() const sessionData = sessionResponse.data @@ -25,7 +29,7 @@ const Consent = () => { } const consentResponse = await fetch( - `/api/consent?consent_challenge=${consent_challenge}`, + `${basePath}/api/consent?consent_challenge=${consent_challenge}`, ) const consentData = await consentResponse.json() @@ -38,7 +42,7 @@ const Consent = () => { // Automatically handle skipping consent if enabled if (consentData.client?.skip_consent) { console.log("Skipping consent, automatically submitting.") - const skipResponse = await fetch("/api/consent", { + const skipResponse = await fetch(`${basePath}/api/consent`, { method: "POST", headers: { "Content-Type": "application/json", @@ -56,12 +60,13 @@ const Consent = () => { if (skipData.error) { throw new Error(skipData.error) } - - router.push(skipData.redirect_to) + window.location.href = skipData.redirect_to + return } } catch (error) { console.error("Error fetching session or consent:", error) } + setIsLoading(false) } if (router.query.consent_challenge) { @@ -87,7 +92,7 @@ const Consent = () => { } try { - const response = await fetch("/api/consent", { + const response = await fetch(`${basePath}/api/consent`, { method: "POST", headers: { "Content-Type": "application/json", @@ -106,14 +111,13 @@ const Consent = () => { console.error("Error submitting consent:", data.error) return } - - router.push(data.redirect_to) + window.location.href = data.redirect_to } catch (error) { console.error("Error during consent submission:", error) } } - if (!consent) { + if (!consent || isLoading) { return
Loading...
} diff --git a/pages/fitbit.tsx b/pages/fitbit.tsx new file mode 100644 index 0000000..b0d91e3 --- /dev/null +++ b/pages/fitbit.tsx @@ -0,0 +1,125 @@ +import { SettingsFlow } from "@ory/client" +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 QRCode from "react-qr-code" + +import { ActionCard, CenterLink, Methods, CardTitle } from "../pkg" +import ory from "../pkg/sdk" +import restSourceClient from "../services/rest-source-client" + +interface Props { + flow?: SettingsFlow + only?: Methods +} + +function AppLoginCard({ children }: Props & { children: ReactNode }) { + return ( + + {children} + + ) +} + +const Fitbit: NextPage = () => { + const router = useRouter() + const { flow: flowId, return_to: returnTo } = router.query + const [traits, setTraits] = useState() + const [projects, setProjects] = useState([]) + const [tokenHandled, setTokenHandled] = useState(false) // Flag to ensure token is handled once + + const handleNavigation = () => { + return restSourceClient.redirectToAuthRequestLink() + } + + useEffect(() => { + ory.toSession().then(({ data }) => { + const traits = data?.identity?.traits + setTraits(traits) + setProjects(traits.projects) + }) + }, [flowId, router, router.isReady, returnTo]) + + useEffect(() => { + const handleToken = async () => { + if (!router.isReady || !projects.length || tokenHandled) return + + const existingToken = localStorage.getItem("access_token") + + if (existingToken) { + await restSourceClient.redirectToRestSourceAuthLink( + existingToken, + projects[0], + ) + setTokenHandled(true) + return + } + + const token = await restSourceClient.getAccessTokenFromRedirect() + if (token) { + localStorage.setItem("access_token", token) + await restSourceClient.redirectToRestSourceAuthLink(token, projects[0]) + setTokenHandled(true) + } + } + + handleToken() + }, [router.isReady, projects, tokenHandled]) + + return ( + <> + + App Login + + + + App Login + + + + + Go back + + + + ) +} + +interface QrFormProps { + projects: any[] + navigate: any +} + +const QrForm: React.FC = ({ projects, navigate }) => { + if (projects) { + return ( +
+ {projects.map((project) => ( +
+

{project.name}

+
+ +

Click the button below to redirect to Fitbit.

+ +
+
+ ))} +
+ ) + } else { + return ( +
+ +
+ ) + } +} + +export default Fitbit diff --git a/pages/health/alive.tsx b/pages/health/alive.tsx new file mode 100644 index 0000000..e705016 --- /dev/null +++ b/pages/health/alive.tsx @@ -0,0 +1,23 @@ +import { ServerResponse } from "http" +import { GetServerSideProps } from "next" +import React from "react" + +// Use Node.js's ServerResponse + +const Alive = () => { + return
Healthy!
+} + +export const getServerSideProps: GetServerSideProps = async ({ + res, +}: { + res: ServerResponse +}) => { + res.statusCode = 200 + + return { + props: {}, + } +} + +export default Alive diff --git a/pages/health/ready.tsx b/pages/health/ready.tsx new file mode 100644 index 0000000..4a95235 --- /dev/null +++ b/pages/health/ready.tsx @@ -0,0 +1,21 @@ +import { ServerResponse } from "http" +import { GetServerSideProps } from "next" +import React from "react" + +const Ready = () => { + return
Ready!
+} + +export const getServerSideProps: GetServerSideProps = async ({ + res, +}: { + res: ServerResponse +}) => { + res.statusCode = 200 + + return { + props: {}, + } +} + +export default Ready diff --git a/pages/index.tsx b/pages/index.tsx index 43dc663..2ba2956 100644 --- a/pages/index.tsx +++ b/pages/index.tsx @@ -15,6 +15,7 @@ const Home: NextPage = () => { const [hasSession, setHasSession] = useState(false) const router = useRouter() const onLogout = LogoutLink() + const handleNavigation = (href: string) => () => router.push(href) useEffect(() => { ory @@ -46,12 +47,12 @@ const Home: NextPage = () => { return (
- Ory NextJS Integration Example - + RADAR Base + - RADAR Base Ory! + RADAR Base Self-Enrolment Portal

Welcome to the RADAR Base self-enrolment portal.

@@ -60,56 +61,62 @@ const Home: NextPage = () => {
+ { const onSubmit = (values: UpdateLoginFlowBody) => 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. .push(`/login?flow=${flow?.id}`, undefined, { shallow: true }) .then(() => ory @@ -89,20 +87,19 @@ const Login: NextPage = () => { flow: String(flow?.id), updateLoginFlowBody: values, }) - // We logged in successfully! Let's bring the user home. .then(() => { if (flow?.return_to) { - window.location.href = flow?.return_to - return + window.location.href = flow.return_to + } else if (loginChallenge) { + window.location.href = `/oauth2/auth/requests/login?login_challenge=${loginChallenge}` + } else { + router.push("/") } - router.push("/") }) .then(() => {}) .catch(handleFlowError(router, "login", setFlow)) .catch((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 LoginFlow) return } @@ -114,8 +111,8 @@ const Login: NextPage = () => { return ( <> - Sign in - Ory NextJS Integration Example - + Sign in - RADAR Base + diff --git a/pages/oauth2-login.tsx b/pages/oauth2-login.tsx new file mode 100644 index 0000000..30f44b5 --- /dev/null +++ b/pages/oauth2-login.tsx @@ -0,0 +1,93 @@ +import axios from "axios" +import Head from "next/head" +import Link from "next/link" +import { useRouter } from "next/router" +import { useEffect, useState } from "react" + +import { MarginCard } from "../pkg" +import ory from "../pkg/sdk" + +const OAuth2Login = () => { + const router = useRouter() + const [challenge, setChallenge] = useState(null) + const [error, setError] = useState(null) + const [traits, setTraits] = useState(null) + const [projects, setProjects] = useState([]) + const [id, setId] = useState(null) + + const basePath = process.env.BASE_PATH || "/kratos-ui" + + useEffect(() => { + const checkSession = async () => { + try { + // Check if a valid Ory Kratos session exists + const { login_challenge } = router.query + const { data } = await ory.toSession() + const traits = data?.identity?.traits + const projects = traits?.projects + const id = data?.identity?.id + setId(data?.identity?.id) + setTraits(traits) + setProjects(traits?.projects) + setChallenge(String(login_challenge)) + + if (traits && login_challenge) { + const subject = projects && projects[0] ? projects[0].userId : id + handleLogin(subject, login_challenge) + } + } catch (error) { + console.error("Error fetching session:", error) + const { login_challenge } = router.query + if (login_challenge) { + router.push(`/login?login_challenge=${login_challenge}`) + } + } + } + + if (!challenge) { + checkSession() + } + }, [router]) + + const handleLogin = async (subject: any, challenge: any) => { + try { + if (!subject || !challenge) throw Error("Subject cannot be null") + const response = await fetch(`${basePath}/api/login`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + loginChallenge: challenge, + subject: subject, + remember: true, + }), + }) + + const data = await response.json() + window.location.href = data.redirect_to + } catch (err) { + console.error("Error during login:", err) + setError("Login failed. Please try again.") + } + } + + const isLoginReady = traits + + return ( +
+ + OAuth2 Login + + +

OAuth2 Login

+ {error &&

{error}

} +

To continue, please log in.

+ +
+
+
+ ) +} + +export default OAuth2Login diff --git a/pages/profile.tsx b/pages/profile.tsx index 58e0e8a..cce06d1 100644 --- a/pages/profile.tsx +++ b/pages/profile.tsx @@ -148,7 +148,7 @@ const Profile: NextPage = () => { <> Profile Page - + User Information diff --git a/pages/recovery.tsx b/pages/recovery.tsx index cfdad09..0712bb9 100644 --- a/pages/recovery.tsx +++ b/pages/recovery.tsx @@ -86,8 +86,8 @@ const Recovery: NextPage = () => { return ( <> - Recover your account - Ory NextJS Integration Example - + Recover your account - RADAR Base + Recover your account diff --git a/pages/registration.tsx b/pages/registration.tsx index a578cb4..cd2a7c8 100644 --- a/pages/registration.tsx +++ b/pages/registration.tsx @@ -4,6 +4,7 @@ import type { NextPage } from "next" import Head from "next/head" import { useRouter } from "next/router" import { useEffect, useRef, useState } from "react" +import { v4 as uuid } from "uuid" // Import render helpers import { ActionCard, CenterLink, Flow, MarginCard, CardTitle } from "../pkg" @@ -74,6 +75,7 @@ const Registration: NextPage = () => { const onSubmit = async (values: UpdateRegistrationFlowBody) => { const project = { id: projectId, + userId: uuid(), name: projectId, eligibility: JSON.parse(eligibility), } diff --git a/pages/settings.tsx b/pages/settings.tsx index 3aa327d..65c588d 100644 --- a/pages/settings.tsx +++ b/pages/settings.tsx @@ -147,7 +147,7 @@ const Settings: NextPage = () => { Profile Management and Security Settings - Ory NextJS Integration Example - + Profile Management and Security Settings diff --git a/pages/study-consent.tsx b/pages/study-consent.tsx index 9534140..1163bc8 100644 --- a/pages/study-consent.tsx +++ b/pages/study-consent.tsx @@ -159,7 +159,7 @@ const StudyConsent: NextPage = () => { <> Study Consent - + Study Consent diff --git a/pages/verification.tsx b/pages/verification.tsx index 2af6318..896585f 100644 --- a/pages/verification.tsx +++ b/pages/verification.tsx @@ -104,8 +104,8 @@ const Verification: NextPage = () => { return ( <> - Verify your account - Ory NextJS Integration Example - + Verify your account - RADAR Base + Verify your account diff --git a/pkg/sdk/index.ts b/pkg/sdk/index.ts index 5d741aa..ac5dab2 100644 --- a/pkg/sdk/index.ts +++ b/pkg/sdk/index.ts @@ -1,15 +1,15 @@ import { Configuration, FrontendApi } from "@ory/client" import { edgeConfig } from "@ory/integrations/next" +import getConfig from "next/config" + +const { publicRuntimeConfig } = getConfig() +const { basePath } = publicRuntimeConfig const localConfig = { - basePath: process.env.NEXT_PUBLIC_KRATOS_PUBLIC_URL, + basePath: `${basePath}/api/.ory`, baseOptions: { withCredentials: true, }, } -export default new FrontendApi( - new Configuration( - process.env.NEXT_PUBLIC_KRATOS_PUBLIC_URL ? localConfig : edgeConfig, - ), -) +export default new FrontendApi(new Configuration(localConfig)) diff --git a/pkg/styled/index.tsx b/pkg/styled/index.tsx index b3afb70..1371b88 100644 --- a/pkg/styled/index.tsx +++ b/pkg/styled/index.tsx @@ -106,17 +106,19 @@ export const DocsButton = ({ testid, disabled, unresponsive, -}: DocsButtonProps) => ( -
-
- - {title} - +}: DocsButtonProps) => { + return ( +
+
+ + {title} + +
-
-) + ) +} diff --git a/services/rest-source-client.ts b/services/rest-source-client.ts new file mode 100644 index 0000000..4950c5a --- /dev/null +++ b/services/rest-source-client.ts @@ -0,0 +1,166 @@ +import getConfig from "next/config" + +const { publicRuntimeConfig } = getConfig() + +export class RestSourceClient { + private readonly AUTH_BASE_URL = `${publicRuntimeConfig.hydraPublicUrl}/oauth2` + private readonly GRANT_TYPE = "authorization_code" + private readonly CLIENT_ID = `${publicRuntimeConfig.frontEndClientId}` + private readonly CLIENT_SECRET = `${publicRuntimeConfig.frontEndClientSecret}` + private readonly REGISTRATION_ENDPOINT = `${publicRuntimeConfig.restSourceBackendEndpoint}/registrations` + private readonly USER_ENDPOINT = `${publicRuntimeConfig.restSourceBackendEndpoint}/users` + private readonly FRONTEND_ENDPOINT = `${publicRuntimeConfig.restSourceFrontendEndpoint}` + private readonly SOURCE_TYPE = "Oura" + + async getRestSourceUser( + accessToken: string, + project: any, + ): Promise { + try { + const response = await fetch(this.USER_ENDPOINT, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${accessToken}`, + }, + body: JSON.stringify({ + userId: project.userId, + projectId: project.id, + sourceType: this.SOURCE_TYPE, + startDate: new Date().toISOString(), + }), + }) + + if (!response.ok) { + const data = await response.json() + if (response.status === 409 && data.user) { + console.warn("User already exists:", data.message) + return data.user.id + } else { + throw new Error( + `Failed to create user: ${data.message || response.statusText}`, + ) + } + } + + const userDto = await response.json() + return userDto.id + } catch (error) { + console.error(error) + return null + } + } + + async getAccessToken( + code: string, + redirectUri: string, + ): Promise { + const bodyParams = new URLSearchParams({ + grant_type: this.GRANT_TYPE, + code, + redirect_uri: redirectUri, + client_id: this.CLIENT_ID, + client_secret: this.CLIENT_SECRET, + }) + + try { + const response = await fetch(`${this.AUTH_BASE_URL}/token`, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: bodyParams, + }) + + if (!response.ok) { + throw new Error( + `Failed to retrieve access token: ${response.statusText}`, + ) + } + + const data = await response.json() + return data.access_token || null + } catch (error) { + console.error(error) + return null + } + } + + async getAccessTokenFromRedirect(): Promise { + const url = new URL(window.location.href) + const code = url.searchParams.get("code") + if (!code) return null + + const redirectUri = window.location.href.split("?")[0] + return this.getAccessToken(code, redirectUri) + } + + redirectToAuthRequestLink(): void { + const scopes = [ + "SOURCETYPE.READ", + "PROJECT.READ", + "SUBJECT.READ", + "SUBJECT.UPDATE", + "SUBJECT.CREATE", + ].join("%20") + + const authUrl = `${this.AUTH_BASE_URL}/auth?client_id=${ + this.CLIENT_ID + }&response_type=code&state=${Date.now()}&audience=res_restAuthorizer&scope=${scopes}&redirect_uri=${ + window.location.href.split("?")[0] + }` + + window.location.href = authUrl + } + + async getRestSourceAuthLink( + accessToken: string, + project: any, + ): Promise { + try { + const userId = await this.getRestSourceUser(accessToken, project) + if (!userId) { + throw new Error("Failed to retrieve or create user") + } + + const response = await fetch(this.REGISTRATION_ENDPOINT, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${accessToken}`, + }, + body: JSON.stringify({ + userId, + persistent: false, + }), + }) + + if (!response.ok) { + throw new Error( + `Failed to retrieve registration token: ${response.statusText}`, + ) + } + + const data = await response.json() + if (!data.token || !data.secret) { + throw new Error("Failed to retrieve auth link") + } + + return `${this.FRONTEND_ENDPOINT}/users:auth?token=${data.token}&secret=${data.secret}` + } catch (error) { + console.error(error) + return null + } + } + + async redirectToRestSourceAuthLink( + accessToken: string, + project: any, + ): Promise { + const url = await this.getRestSourceAuthLink(accessToken, project) + if (url) { + console.log("Redirecting to: ", url) + window.location.href = url + } + } +} + +export default new RestSourceClient()