diff --git a/README.md b/README.md index f883d97..f0a4f3c 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,25 @@ With the integration of Tailwind CSS, you get access to utility-first CSS for ra - Login Idp Link Email - Login Oauth Grant - Login Otp +- Login Page Expired +- Login Password +- Login Reset Otp +- Login Updatte Password +- Login Update Profile +- Login Username +- Login Verify Email +- Login x509 Info +- Logout Confirm +- Saml Post Form +- Select Authenticator +- Update Email +- Web Authn Authenticate +- Web Authn Error +- Web Authn Register +- Web Authn Error +- Login Oauth2 Device Verify User Code +- Login Recovery Authn Code Config +- Login Recovery Authn Code input Stay tuned as more pages are upgraded with Tailwind CSS and ShadCN UI components! diff --git a/src/login/KcPage.tsx b/src/login/KcPage.tsx index 5b5f366..0da6fbf 100644 --- a/src/login/KcPage.tsx +++ b/src/login/KcPage.tsx @@ -32,6 +32,13 @@ import LoginX509Info from "./pages/LoginX509Info"; import LogoutConfirm from "./pages/LogoutConfirm"; import SamlPostForm from "./pages/SamlPostForm"; import SelectAuthenticator from "./pages/SelectAuthenticator"; +import UpdateEmail from "./pages/UpdateEmail"; +import WebauthnAuthenticate from "./pages/WebauthnAuthenticate"; +import WebauthnError from "./pages/WebauthnError"; +import WebauthnRegister from "./pages/WebauthnRegister"; +import LoginOauth2DeviceVerifyUserCode from "./pages/LoginOauth2DeviceVerifyUserCode"; +import LoginRecoveryAuthnCodeConfig from "./pages/LoginRecoveryAuthnCodeConfig"; +import LoginRecoveryAuthnCodeInput from "./pages/LoginRecoveryAuthnCodeInput"; const UserProfileFormFields = lazy(() => import("./UserProfileFormFields")); // Base component to render DefaultPage @@ -67,6 +74,78 @@ export default function KcPage(props: { kcContext: KcContext }) { {(() => { switch (kcContext.pageId) { + case "login-recovery-authn-code-input.ftl": + return ( + + ); + case "login-recovery-authn-code-config.ftl": + return ( + + ); + case "login-oauth2-device-verify-user-code.ftl": + return ( + + ); + case "webauthn-register.ftl": + return ( + + ); + case "webauthn-error.ftl": + return ( + + ); + case "webauthn-authenticate.ftl": + return ( + + ); + case "update-email.ftl": + return ( + + ); case "select-authenticator.ftl": return ( ; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + render: () => +}; diff --git a/src/login/pages/LoginOauth2DeviceVerifyUserCode.tsx b/src/login/pages/LoginOauth2DeviceVerifyUserCode.tsx new file mode 100644 index 0000000..4f9d6da --- /dev/null +++ b/src/login/pages/LoginOauth2DeviceVerifyUserCode.tsx @@ -0,0 +1,73 @@ +import { getKcClsx } from "keycloakify/login/lib/kcClsx"; +import { PageProps } from "keycloakify/login/pages/PageProps"; +import { KcContext } from "../KcContext"; +import type { I18n } from "../i18n"; +import { buttonVariants } from "../../components/ui/button"; +import { Input } from "../../components/ui/input"; +import { cn } from "../../lib/utils"; +export default function LoginOauth2DeviceVerifyUserCode( + props: PageProps, I18n> +) { + const { kcContext, i18n, doUseDefaultCss, classes, Template } = props; + const { url } = kcContext; + + const { msg, msgStr } = i18n; + + const { kcClsx } = getKcClsx({ + doUseDefaultCss, + classes + }); + + return ( + + ); +} diff --git a/src/login/pages/LoginOauthGrant.tsx b/src/login/pages/LoginOauthGrant.tsx index 71c3892..9f15e90 100644 --- a/src/login/pages/LoginOauthGrant.tsx +++ b/src/login/pages/LoginOauthGrant.tsx @@ -2,7 +2,7 @@ import { getKcClsx } from "keycloakify/login/lib/kcClsx"; import { PageProps } from "keycloakify/login/pages/PageProps"; import { KcContext } from "../KcContext"; import type { I18n } from "../i18n"; -import { buttonVariants } from "@/components/ui/button"; +import { buttonVariants } from "../../components/ui/button"; export default function LoginOauthGrant(props: PageProps, I18n>) { const { kcContext, i18n, doUseDefaultCss, classes, Template } = props; const { url, oauth, client } = kcContext; diff --git a/src/login/pages/LoginPageExpired.tsx b/src/login/pages/LoginPageExpired.tsx index 84e11fd..88e30cc 100644 --- a/src/login/pages/LoginPageExpired.tsx +++ b/src/login/pages/LoginPageExpired.tsx @@ -1,7 +1,7 @@ import type { PageProps } from "keycloakify/login/pages/PageProps"; import type { KcContext } from "../KcContext"; import type { I18n } from "../i18n"; -import { buttonVariants } from "@/components/ui/button"; +import { buttonVariants } from "../../components/ui/button"; import { cn } from "../../lib/utils"; export default function LoginPageExpired(props: PageProps, I18n>) { diff --git a/src/login/pages/LoginPassword.tsx b/src/login/pages/LoginPassword.tsx index f84e9f3..ed73fc1 100644 --- a/src/login/pages/LoginPassword.tsx +++ b/src/login/pages/LoginPassword.tsx @@ -8,7 +8,7 @@ import type { I18n } from "../i18n"; import { PasswordWrapper } from "../../components/ui/PasswordWrapper"; import { buttonVariants } from "../../components/ui/button"; import { cn } from "../../lib/utils"; -import { Input } from "@/components/ui/input"; +import { Input } from "../../components/ui/input"; export default function LoginPassword(props: PageProps, I18n>) { const { kcContext, i18n, doUseDefaultCss, Template, classes } = props; diff --git a/src/login/pages/LoginRecoveryAuthnCodeConfig.stories.tsx b/src/login/pages/LoginRecoveryAuthnCodeConfig.stories.tsx new file mode 100644 index 0000000..95ecec3 --- /dev/null +++ b/src/login/pages/LoginRecoveryAuthnCodeConfig.stories.tsx @@ -0,0 +1,26 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { createKcPageStory } from "../KcPageStory"; + +const { KcPageStory } = createKcPageStory({ pageId: "login-recovery-authn-code-config.ftl" }); + +const meta = { + title: "login/login-recovery-authn-code-config.ftl", + component: KcPageStory +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + render: () => +}; +export const WithAppInitiatedAction: Story = { + render: () => ( + + ) +}; diff --git a/src/login/pages/LoginRecoveryAuthnCodeConfig.tsx b/src/login/pages/LoginRecoveryAuthnCodeConfig.tsx new file mode 100644 index 0000000..f4ccce9 --- /dev/null +++ b/src/login/pages/LoginRecoveryAuthnCodeConfig.tsx @@ -0,0 +1,281 @@ +import { useEffect } from "react"; +import { clsx } from "keycloakify/tools/clsx"; +import { getKcClsx, type KcClsx } from "keycloakify/login/lib/kcClsx"; +import { useInsertScriptTags } from "keycloakify/tools/useInsertScriptTags"; +import type { PageProps } from "keycloakify/login/pages/PageProps"; +import type { KcContext } from "../KcContext"; +import type { I18n } from "../i18n"; +import { checkboxVariants } from "../../components/ui/checkbox"; +import { buttonVariants, Button } from "../../components/ui/button"; +import { cn } from "../../lib/utils"; + +export default function LoginRecoveryAuthnCodeConfig(props: PageProps, I18n>) { + const { kcContext, i18n, doUseDefaultCss, Template, classes } = props; + + const { kcClsx } = getKcClsx({ + doUseDefaultCss, + classes + }); + + const { recoveryAuthnCodesConfigBean, isAppInitiatedAction } = kcContext; + + const { msg, msgStr } = i18n; + + const { insertScriptTags } = useInsertScriptTags({ + componentOrHookName: "LoginRecoveryAuthnCodeConfig", + scriptTags: [ + { + type: "text/javascript", + textContent: ` + + /* copy recovery codes */ + function copyRecoveryCodes() { + var tmpTextarea = document.createElement("textarea"); + var codes = document.getElementById("kc-recovery-codes-list").getElementsByTagName("li"); + for (i = 0; i < codes.length; i++) { + tmpTextarea.value = tmpTextarea.value + codes[i].innerText + "\\n"; + } + document.body.appendChild(tmpTextarea); + tmpTextarea.select(); + document.execCommand("copy"); + document.body.removeChild(tmpTextarea); + } + + var copyButton = document.getElementById("copyRecoveryCodes"); + copyButton && copyButton.addEventListener("click", function () { + copyRecoveryCodes(); + }); + + /* download recovery codes */ + function formatCurrentDateTime() { + var dt = new Date(); + var options = { + month: 'long', + day: 'numeric', + year: 'numeric', + hour: 'numeric', + minute: 'numeric', + timeZoneName: 'short' + }; + + return dt.toLocaleString('en-US', options); + } + + function parseRecoveryCodeList() { + var recoveryCodes = document.querySelectorAll(".kc-recovery-codes-list li"); + var recoveryCodeList = ""; + + for (var i = 0; i < recoveryCodes.length; i++) { + var recoveryCodeLiElement = recoveryCodes[i].innerText; + recoveryCodeList += recoveryCodeLiElement + "\\r\\n"; + } + + return recoveryCodeList; + } + + function buildDownloadContent() { + var recoveryCodeList = parseRecoveryCodeList(); + var dt = new Date(); + var options = { + month: 'long', + day: 'numeric', + year: 'numeric', + hour: 'numeric', + minute: 'numeric', + timeZoneName: 'short' + }; + + return fileBodyContent = + "${msgStr("recovery-codes-download-file-header")}\\n\\n" + + recoveryCodeList + "\\n" + + "${msgStr("recovery-codes-download-file-description")}\\n\\n" + + "${msgStr("recovery-codes-download-file-date")} " + formatCurrentDateTime(); + } + + function setUpDownloadLinkAndDownload(filename, text) { + var el = document.createElement('a'); + el.setAttribute('href', 'data:text/plain;charset=utf-8,' + encodeURIComponent(text)); + el.setAttribute('download', filename); + el.style.display = 'none'; + document.body.appendChild(el); + el.click(); + document.body.removeChild(el); + } + + function downloadRecoveryCodes() { + setUpDownloadLinkAndDownload('kc-download-recovery-codes.txt', buildDownloadContent()); + } + + var downloadButton = document.getElementById("downloadRecoveryCodes"); + downloadButton && downloadButton.addEventListener("click", downloadRecoveryCodes); + + /* print recovery codes */ + function buildPrintContent() { + var recoveryCodeListHTML = document.getElementById('kc-recovery-codes-list').innerHTML; + var styles = + \`@page { size: auto; margin-top: 0; } + body { width: 480px; } + div { list-style-type: none; font-family: monospace } + p:first-of-type { margin-top: 48px }\`; + + return printFileContent = + "" + + "kc-download-recovery-codes" + + "

${msgStr("recovery-codes-download-file-header")}

" + + "
" + recoveryCodeListHTML + "
" + + "

${msgStr("recovery-codes-download-file-description")}

" + + "

${msgStr("recovery-codes-download-file-date")} " + formatCurrentDateTime() + "

" + + ""; + } + + function printRecoveryCodes() { + var w = window.open(); + w.document.write(buildPrintContent()); + w.print(); + w.close(); + } + + var printButton = document.getElementById("printRecoveryCodes"); + printButton && printButton.addEventListener("click", printRecoveryCodes); + ` + } + ] + }); + + useEffect(() => { + insertScriptTags(); + }, []); + + return ( + + ); +} + +function LogoutOtherSessions(props: { kcClsx: KcClsx; i18n: I18n }) { + const { kcClsx, i18n } = props; + + const { msg } = i18n; + + return ( +
+
+
+
+ + {msg("logoutOtherSessions")} +
+
+
+
+ ); +} diff --git a/src/login/pages/LoginRecoveryAuthnCodeInput.stories.tsx b/src/login/pages/LoginRecoveryAuthnCodeInput.stories.tsx new file mode 100644 index 0000000..861e05d --- /dev/null +++ b/src/login/pages/LoginRecoveryAuthnCodeInput.stories.tsx @@ -0,0 +1,33 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { createKcPageStory } from "../KcPageStory"; + +const { KcPageStory } = createKcPageStory({ pageId: "login-recovery-authn-code-input.ftl" }); + +const meta = { + title: "login/login-recovery-authn-code-input.ftl", + component: KcPageStory +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + render: () => +}; +// Error case where the user enters an incorrect recovery code +export const WithError: Story = { + render: () => ( + fieldName === "recoveryCodeInput", + get: (fieldName: string) => (fieldName === "recoveryCodeInput" ? "Invalid recovery code. Please try again." : undefined) + }, + recoveryAuthnCodesInputBean: { + codeNumber: 3 + } + }} + /> + ) +}; diff --git a/src/login/pages/LoginRecoveryAuthnCodeInput.tsx b/src/login/pages/LoginRecoveryAuthnCodeInput.tsx new file mode 100644 index 0000000..57cb9f3 --- /dev/null +++ b/src/login/pages/LoginRecoveryAuthnCodeInput.tsx @@ -0,0 +1,71 @@ +import { getKcClsx } from "keycloakify/login/lib/kcClsx"; +import type { PageProps } from "keycloakify/login/pages/PageProps"; +import type { KcContext } from "../KcContext"; +import type { I18n } from "../i18n"; +import { buttonVariants } from "../../components/ui/button"; +import { Input } from "../../components/ui/input"; +import { cn } from "../../lib/utils"; +export default function LoginRecoveryAuthnCodeInput(props: PageProps, I18n>) { + const { kcContext, i18n, doUseDefaultCss, Template, classes } = props; + + const { kcClsx } = getKcClsx({ + doUseDefaultCss, + classes + }); + + const { url, messagesPerField, recoveryAuthnCodesInputBean } = kcContext; + + const { msg, msgStr } = i18n; + + return ( + + ); +} diff --git a/src/login/pages/LoginResetOtp.tsx b/src/login/pages/LoginResetOtp.tsx index 7eab25f..f2121cf 100644 --- a/src/login/pages/LoginResetOtp.tsx +++ b/src/login/pages/LoginResetOtp.tsx @@ -3,8 +3,8 @@ import { getKcClsx } from "keycloakify/login/lib/kcClsx"; import type { PageProps } from "keycloakify/login/pages/PageProps"; import type { KcContext } from "../KcContext"; import type { I18n } from "../i18n"; -import { buttonVariants } from "@/components/ui/button"; -import { cn } from "@/lib/utils"; +import { buttonVariants } from "../../components/ui/button"; +import { cn } from "../../lib/utils"; export default function LoginResetOtp(props: PageProps, I18n>) { const { kcContext, i18n, doUseDefaultCss, Template, classes } = props; diff --git a/src/login/pages/LoginResetPassword.tsx b/src/login/pages/LoginResetPassword.tsx index aefe763..9d7461a 100644 --- a/src/login/pages/LoginResetPassword.tsx +++ b/src/login/pages/LoginResetPassword.tsx @@ -4,7 +4,7 @@ import type { KcContext } from "../KcContext"; import type { I18n } from "../i18n"; import { buttonVariants } from "../../components/ui/button"; import { cn } from "../../lib/utils"; -import { Input } from "@/components/ui/input"; +import { Input } from "../../components/ui/input"; export default function LoginResetPassword(props: PageProps, I18n>) { const { kcContext, i18n, doUseDefaultCss, Template, classes } = props; diff --git a/src/login/pages/UpdateEmail.stories.tsx b/src/login/pages/UpdateEmail.stories.tsx new file mode 100644 index 0000000..d3fcbc0 --- /dev/null +++ b/src/login/pages/UpdateEmail.stories.tsx @@ -0,0 +1,28 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { createKcPageStory } from "../KcPageStory"; + +const { KcPageStory } = createKcPageStory({ pageId: "update-email.ftl" }); + +const meta = { + title: "login/update-email.ftl", + component: KcPageStory +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + render: () => +}; + +// 2. App-Initiated Action +export const AppInitiatedAction: Story = { + render: () => ( + + ) +}; diff --git a/src/login/pages/UpdateEmail.tsx b/src/login/pages/UpdateEmail.tsx new file mode 100644 index 0000000..31b86c5 --- /dev/null +++ b/src/login/pages/UpdateEmail.tsx @@ -0,0 +1,111 @@ +import { useState } from "react"; +import type { LazyOrNot } from "keycloakify/tools/LazyOrNot"; +import { getKcClsx, type KcClsx } from "keycloakify/login/lib/kcClsx"; +import type { UserProfileFormFieldsProps } from "keycloakify/login/UserProfileFormFieldsProps"; +import type { PageProps } from "keycloakify/login/pages/PageProps"; +import type { KcContext } from "../KcContext"; +import type { I18n } from "../i18n"; +import { buttonVariants, Button } from "../../components/ui/button"; +import { cn } from "../../lib/utils"; +import { checkboxVariants } from "../../components/ui/checkbox"; +type UpdateEmailProps = PageProps, I18n> & { + UserProfileFormFields: LazyOrNot<(props: UserProfileFormFieldsProps) => JSX.Element>; + doMakeUserConfirmPassword: boolean; +}; + +export default function UpdateEmail(props: UpdateEmailProps) { + const { kcContext, i18n, doUseDefaultCss, Template, classes, UserProfileFormFields, doMakeUserConfirmPassword } = props; + + const { kcClsx } = getKcClsx({ + doUseDefaultCss, + classes + }); + + const { msg, msgStr } = i18n; + + const [isFormSubmittable, setIsFormSubmittable] = useState(false); + + const { url, messagesPerField, isAppInitiatedAction } = kcContext; + + return ( + + ); +} + +function LogoutOtherSessions(props: { kcClsx: KcClsx; i18n: I18n }) { + const { kcClsx, i18n } = props; + + const { msg } = i18n; + + return ( +
+
+
+
+ + {msg("logoutOtherSessions")} +
+
+
+
+ ); +} diff --git a/src/login/pages/WebauthnAuthenticate.stories.tsx b/src/login/pages/WebauthnAuthenticate.stories.tsx new file mode 100644 index 0000000..e2a89c1 --- /dev/null +++ b/src/login/pages/WebauthnAuthenticate.stories.tsx @@ -0,0 +1,82 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { createKcPageStory } from "../KcPageStory"; + +const { KcPageStory } = createKcPageStory({ pageId: "webauthn-authenticate.ftl" }); + +const meta = { + title: "login/webauthn-authenticate.ftl", + component: KcPageStory +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + render: () => +}; +export const NoAuthenticators: Story = { + render: () => ( + + ) +}; +export const MultipleAuthenticators: Story = { + render: () => ( + + ) +}; +export const SingleAuthenticator: Story = { + render: () => ( + + ) +}; diff --git a/src/login/pages/WebauthnAuthenticate.tsx b/src/login/pages/WebauthnAuthenticate.tsx new file mode 100644 index 0000000..419210b --- /dev/null +++ b/src/login/pages/WebauthnAuthenticate.tsx @@ -0,0 +1,254 @@ +import { useEffect, Fragment } from "react"; +import { assert } from "keycloakify/tools/assert"; +import { clsx } from "keycloakify/tools/clsx"; +import { useInsertScriptTags } from "keycloakify/tools/useInsertScriptTags"; +import { getKcClsx } from "keycloakify/login/lib/kcClsx"; +import type { PageProps } from "keycloakify/login/pages/PageProps"; +import type { KcContext } from "../KcContext"; +import type { I18n } from "../i18n"; +import { buttonVariants } from "../../components/ui/button"; +import { cn } from "../../lib/utils"; + +export default function WebauthnAuthenticate(props: PageProps, I18n>) { + const { kcContext, i18n, doUseDefaultCss, Template, classes } = props; + + const { kcClsx } = getKcClsx({ doUseDefaultCss, classes }); + + const { + url, + isUserIdentified, + challenge, + userVerification, + rpId, + createTimeout, + messagesPerField, + realm, + registrationDisabled, + authenticators, + shouldDisplayAuthenticators + } = kcContext; + + const { msg, msgStr, advancedMsg } = i18n; + + const { insertScriptTags } = useInsertScriptTags({ + componentOrHookName: "WebauthnAuthenticate", + scriptTags: [ + { + type: "text/javascript", + src: `${url.resourcesCommonPath}/node_modules/jquery/dist/jquery.min.js` + }, + { + type: "text/javascript", + src: `${url.resourcesPath}/js/base64url.js` + }, + { + type: "text/javascript", + textContent: ` + + function webAuthnAuthenticate() { + let isUserIdentified = ${isUserIdentified}; + if (!isUserIdentified) { + doAuthenticate([]); + return; + } + checkAllowCredentials(); + } + + function checkAllowCredentials() { + let allowCredentials = []; + let authn_use = document.forms['authn_select'].authn_use_chk; + + if (authn_use !== undefined) { + if (authn_use.length === undefined) { + allowCredentials.push({ + id: base64url.decode(authn_use.value, {loose: true}), + type: 'public-key', + }); + } else { + for (let i = 0; i < authn_use.length; i++) { + allowCredentials.push({ + id: base64url.decode(authn_use[i].value, {loose: true}), + type: 'public-key', + }); + } + } + } + doAuthenticate(allowCredentials); + } + + + function doAuthenticate(allowCredentials) { + + // Check if WebAuthn is supported by this browser + if (!window.PublicKeyCredential) { + $("#error").val("${msgStr("webauthn-unsupported-browser-text")}"); + $("#webauth").submit(); + return; + } + + let challenge = "${challenge}"; + let userVerification = "${userVerification}"; + let rpId = "${rpId}"; + let publicKey = { + rpId : rpId, + challenge: base64url.decode(challenge, { loose: true }) + }; + + let createTimeout = ${createTimeout}; + if (createTimeout !== 0) publicKey.timeout = createTimeout * 1000; + + if (allowCredentials.length) { + publicKey.allowCredentials = allowCredentials; + } + + if (userVerification !== 'not specified') publicKey.userVerification = userVerification; + + navigator.credentials.get({publicKey}) + .then((result) => { + window.result = result; + + let clientDataJSON = result.response.clientDataJSON; + let authenticatorData = result.response.authenticatorData; + let signature = result.response.signature; + + $("#clientDataJSON").val(base64url.encode(new Uint8Array(clientDataJSON), { pad: false })); + $("#authenticatorData").val(base64url.encode(new Uint8Array(authenticatorData), { pad: false })); + $("#signature").val(base64url.encode(new Uint8Array(signature), { pad: false })); + $("#credentialId").val(result.id); + if(result.response.userHandle) { + $("#userHandle").val(base64url.encode(new Uint8Array(result.response.userHandle), { pad: false })); + } + $("#webauth").submit(); + }) + .catch((err) => { + $("#error").val(err); + $("#webauth").submit(); + }) + ; + } + + ` + } + ] + }); + + useEffect(() => { + insertScriptTags(); + }, []); + + return ( + + ); +} diff --git a/src/login/pages/WebauthnError.stories.tsx b/src/login/pages/WebauthnError.stories.tsx new file mode 100644 index 0000000..b3a874c --- /dev/null +++ b/src/login/pages/WebauthnError.stories.tsx @@ -0,0 +1,26 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { createKcPageStory } from "../KcPageStory"; + +const { KcPageStory } = createKcPageStory({ pageId: "webauthn-error.ftl" }); + +const meta = { + title: "login/webauthn-error.ftl", + component: KcPageStory +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + render: () => +}; +export const WithoutAppInitiatedAction: Story = { + render: () => ( + + ) +}; diff --git a/src/login/pages/WebauthnError.tsx b/src/login/pages/WebauthnError.tsx new file mode 100644 index 0000000..14ad2e1 --- /dev/null +++ b/src/login/pages/WebauthnError.tsx @@ -0,0 +1,66 @@ +import { getKcClsx } from "keycloakify/login/lib/kcClsx"; +import type { PageProps } from "keycloakify/login/pages/PageProps"; +import type { KcContext } from "../KcContext"; +import type { I18n } from "../i18n"; +import { Button, buttonVariants } from "../../components/ui/button"; +import { cn } from "../../lib/utils"; +export default function WebauthnError(props: PageProps, I18n>) { + const { kcContext, i18n, doUseDefaultCss, Template, classes } = props; + + const { url, isAppInitiatedAction } = kcContext; + + const { msg, msgStr } = i18n; + + const { kcClsx } = getKcClsx({ + doUseDefaultCss, + classes + }); + + return ( + + ); +} diff --git a/src/login/pages/WebauthnRegister.stories.tsx b/src/login/pages/WebauthnRegister.stories.tsx new file mode 100644 index 0000000..9551e7d --- /dev/null +++ b/src/login/pages/WebauthnRegister.stories.tsx @@ -0,0 +1,33 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { createKcPageStory } from "../KcPageStory"; + +const { KcPageStory } = createKcPageStory({ pageId: "webauthn-register.ftl" }); + +const meta = { + title: "login/webauthn-register.ftl", + component: KcPageStory +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + render: () => ( + + ) +}; + +export const WithoutAppInitiatedAction: Story = { + render: () => ( + + ) +}; diff --git a/src/login/pages/WebauthnRegister.tsx b/src/login/pages/WebauthnRegister.tsx new file mode 100644 index 0000000..82e2e73 --- /dev/null +++ b/src/login/pages/WebauthnRegister.tsx @@ -0,0 +1,291 @@ +import { useEffect } from "react"; +import { assert } from "keycloakify/tools/assert"; +import { getKcClsx, type KcClsx } from "keycloakify/login/lib/kcClsx"; +import { useInsertScriptTags } from "keycloakify/tools/useInsertScriptTags"; +import type { PageProps } from "keycloakify/login/pages/PageProps"; +import type { KcContext } from "../KcContext"; +import type { I18n } from "../i18n"; +import { buttonVariants, Button } from "../../components/ui/button"; +import { cn } from "../../lib/utils"; +import { checkboxVariants } from "../../components/ui/checkbox"; + +export default function WebauthnRegister(props: PageProps, I18n>) { + const { kcContext, i18n, doUseDefaultCss, Template, classes } = props; + + const { kcClsx } = getKcClsx({ doUseDefaultCss, classes }); + + const { + url, + challenge, + userid, + username, + signatureAlgorithms, + rpEntityName, + rpId, + attestationConveyancePreference, + authenticatorAttachment, + requireResidentKey, + userVerificationRequirement, + createTimeout, + excludeCredentialIds, + isSetRetry, + isAppInitiatedAction + } = kcContext; + + const { msg, msgStr } = i18n; + + const { insertScriptTags } = useInsertScriptTags({ + componentOrHookName: "WebauthnRegister", + scriptTags: [ + { + type: "text/javascript", + src: `${url.resourcesCommonPath}/node_modules/jquery/dist/jquery.min.js` + }, + { + type: "text/javascript", + src: `${url.resourcesPath}/js/base64url.js` + }, + { + type: "text/javascript", + textContent: ` + function registerSecurityKey() { + + // Check if WebAuthn is supported by this browser + if (!window.PublicKeyCredential) { + $("#error").val("${msgStr("webauthn-unsupported-browser-text")}"); + $("#register").submit(); + return; + } + + // mandatory parameters + let challenge = "${challenge}"; + let userid = "${userid}"; + let username = "${username}"; + + let signatureAlgorithms =${JSON.stringify(signatureAlgorithms)}; + let pubKeyCredParams = getPubKeyCredParams(signatureAlgorithms); + + let rpEntityName = "${rpEntityName}"; + let rp = {name: rpEntityName}; + + let publicKey = { + challenge: base64url.decode(challenge, {loose: true}), + rp: rp, + user: { + id: base64url.decode(userid, {loose: true}), + name: username, + displayName: username + }, + pubKeyCredParams: pubKeyCredParams, + }; + + // optional parameters + let rpId = "${rpId}"; + publicKey.rp.id = rpId; + + let attestationConveyancePreference = "${attestationConveyancePreference}"; + if (attestationConveyancePreference !== 'not specified') publicKey.attestation = attestationConveyancePreference; + + let authenticatorSelection = {}; + let isAuthenticatorSelectionSpecified = false; + + let authenticatorAttachment = "${authenticatorAttachment}"; + if (authenticatorAttachment !== 'not specified') { + authenticatorSelection.authenticatorAttachment = authenticatorAttachment; + isAuthenticatorSelectionSpecified = true; + } + + let requireResidentKey = "${requireResidentKey}"; + if (requireResidentKey !== 'not specified') { + if (requireResidentKey === 'Yes') + authenticatorSelection.requireResidentKey = true; + else + authenticatorSelection.requireResidentKey = false; + isAuthenticatorSelectionSpecified = true; + } + + let userVerificationRequirement = "${userVerificationRequirement}"; + if (userVerificationRequirement !== 'not specified') { + authenticatorSelection.userVerification = userVerificationRequirement; + isAuthenticatorSelectionSpecified = true; + } + + if (isAuthenticatorSelectionSpecified) publicKey.authenticatorSelection = authenticatorSelection; + + let createTimeout = ${createTimeout}; + if (createTimeout !== 0) publicKey.timeout = createTimeout * 1000; + + let excludeCredentialIds = "${excludeCredentialIds}"; + let excludeCredentials = getExcludeCredentials(excludeCredentialIds); + if (excludeCredentials.length > 0) publicKey.excludeCredentials = excludeCredentials; + + navigator.credentials.create({publicKey}) + .then(function (result) { + window.result = result; + let clientDataJSON = result.response.clientDataJSON; + let attestationObject = result.response.attestationObject; + let publicKeyCredentialId = result.rawId; + + $("#clientDataJSON").val(base64url.encode(new Uint8Array(clientDataJSON), {pad: false})); + $("#attestationObject").val(base64url.encode(new Uint8Array(attestationObject), {pad: false})); + $("#publicKeyCredentialId").val(base64url.encode(new Uint8Array(publicKeyCredentialId), {pad: false})); + + if (typeof result.response.getTransports === "function") { + let transports = result.response.getTransports(); + if (transports) { + $("#transports").val(getTransportsAsString(transports)); + } + } else { + console.log("Your browser is not able to recognize supported transport media for the authenticator."); + } + + let initLabel = "WebAuthn Authenticator (Default Label)"; + let labelResult = window.prompt("Please input your registered authenticator's label", initLabel); + if (labelResult === null) labelResult = initLabel; + $("#authenticatorLabel").val(labelResult); + + $("#register").submit(); + + }) + .catch(function (err) { + $("#error").val(err); + $("#register").submit(); + + }); + } + + function getPubKeyCredParams(signatureAlgorithmsList) { + let pubKeyCredParams = []; + if (signatureAlgorithmsList.length === 0) { + pubKeyCredParams.push({type: "public-key", alg: -7}); + return pubKeyCredParams; + } + + for (let i = 0; i < signatureAlgorithmsList.length; i++) { + pubKeyCredParams.push({ + type: "public-key", + alg: signatureAlgorithmsList[i] + }); + } + return pubKeyCredParams; + } + + function getExcludeCredentials(excludeCredentialIds) { + let excludeCredentials = []; + if (excludeCredentialIds === "") return excludeCredentials; + + let excludeCredentialIdsList = excludeCredentialIds.split(','); + + for (let i = 0; i < excludeCredentialIdsList.length; i++) { + excludeCredentials.push({ + type: "public-key", + id: base64url.decode(excludeCredentialIdsList[i], + {loose: true}) + }); + } + return excludeCredentials; + } + + function getTransportsAsString(transportsList) { + if (transportsList === '' || Array.isArray(transportsList)) return ""; + + let transportsString = ""; + + for (let i = 0; i < transportsList.length; i++) { + transportsString += transportsList[i] + ","; + } + + return transportsString.slice(0, -1); + } + ` + } + ] + }); + + useEffect(() => { + insertScriptTags(); + }, []); + + return ( + + ); +} + +function LogoutOtherSessions(props: { kcClsx: KcClsx; i18n: I18n }) { + const { kcClsx, i18n } = props; + + const { msg } = i18n; + + return ( +
+
+
+
+ + {msg("logoutOtherSessions")} +
+
+
+
+ ); +} diff --git a/src/login/pages/legacy/LoginOauth2DeviceVerifyUserCodeLegacyy.tsx b/src/login/pages/legacy/LoginOauth2DeviceVerifyUserCodeLegacyy.tsx new file mode 100644 index 0000000..a2ca0fc --- /dev/null +++ b/src/login/pages/legacy/LoginOauth2DeviceVerifyUserCodeLegacyy.tsx @@ -0,0 +1,70 @@ +import { getKcClsx } from "keycloakify/login/lib/kcClsx"; +import { PageProps } from "keycloakify/login/pages/PageProps"; +import { KcContext } from "../../KcContext"; +import type { I18n } from "../../i18n"; + +export default function LoginOauth2DeviceVerifyUserCode( + props: PageProps, I18n> +) { + const { kcContext, i18n, doUseDefaultCss, classes, Template } = props; + const { url } = kcContext; + + const { msg, msgStr } = i18n; + + const { kcClsx } = getKcClsx({ + doUseDefaultCss, + classes + }); + + return ( + + ); +} diff --git a/src/login/pages/legacy/LoginPasswordLegacy.tsx b/src/login/pages/legacy/LoginPasswordLegacy.tsx deleted file mode 100644 index 5fe8ad8..0000000 --- a/src/login/pages/legacy/LoginPasswordLegacy.tsx +++ /dev/null @@ -1,108 +0,0 @@ -import { useState, useEffect, useReducer } from "react"; -import { clsx } from "keycloakify/tools/clsx"; -import { assert } from "keycloakify/tools/assert"; -import { getKcClsx, type KcClsx } from "keycloakify/login/lib/kcClsx"; -import type { PageProps } from "keycloakify/login/pages/PageProps"; -import type { KcContext } from "../../KcContext"; -import type { I18n } from "../../i18n"; -import { PasswordWrapper } from "../../../components/ui/PasswordWrapper"; -import { buttonVariants } from "../../../components/ui/button"; -import { cn } from "../../../lib/utils"; -import { Input } from "@/components/ui/input"; -export default function LoginPassword(props: PageProps, I18n>) { - const { kcContext, i18n, doUseDefaultCss, Template, classes } = props; - - const { kcClsx } = getKcClsx({ - doUseDefaultCss, - classes - }); - - const { realm, url, messagesPerField } = kcContext; - - const { msg, msgStr } = i18n; - - const [isLoginButtonDisabled, setIsLoginButtonDisabled] = useState(false); - - return ( - - ); -} diff --git a/src/login/pages/legacy/LoginRecoveryAuthnCodeConfigLegacy.tsx b/src/login/pages/legacy/LoginRecoveryAuthnCodeConfigLegacy.tsx new file mode 100644 index 0000000..91f9ce8 --- /dev/null +++ b/src/login/pages/legacy/LoginRecoveryAuthnCodeConfigLegacy.tsx @@ -0,0 +1,260 @@ +import { useEffect } from "react"; +import { clsx } from "keycloakify/tools/clsx"; +import { getKcClsx, type KcClsx } from "keycloakify/login/lib/kcClsx"; +import { useInsertScriptTags } from "keycloakify/tools/useInsertScriptTags"; +import type { PageProps } from "keycloakify/login/pages/PageProps"; +import type { KcContext } from "../../KcContext"; +import type { I18n } from "../../i18n"; + +export default function LoginRecoveryAuthnCodeConfig(props: PageProps, I18n>) { + const { kcContext, i18n, doUseDefaultCss, Template, classes } = props; + + const { kcClsx } = getKcClsx({ + doUseDefaultCss, + classes + }); + + const { recoveryAuthnCodesConfigBean, isAppInitiatedAction } = kcContext; + + const { msg, msgStr } = i18n; + + const { insertScriptTags } = useInsertScriptTags({ + componentOrHookName: "LoginRecoveryAuthnCodeConfig", + scriptTags: [ + { + type: "text/javascript", + textContent: ` + + /* copy recovery codes */ + function copyRecoveryCodes() { + var tmpTextarea = document.createElement("textarea"); + var codes = document.getElementById("kc-recovery-codes-list").getElementsByTagName("li"); + for (i = 0; i < codes.length; i++) { + tmpTextarea.value = tmpTextarea.value + codes[i].innerText + "\\n"; + } + document.body.appendChild(tmpTextarea); + tmpTextarea.select(); + document.execCommand("copy"); + document.body.removeChild(tmpTextarea); + } + + var copyButton = document.getElementById("copyRecoveryCodes"); + copyButton && copyButton.addEventListener("click", function () { + copyRecoveryCodes(); + }); + + /* download recovery codes */ + function formatCurrentDateTime() { + var dt = new Date(); + var options = { + month: 'long', + day: 'numeric', + year: 'numeric', + hour: 'numeric', + minute: 'numeric', + timeZoneName: 'short' + }; + + return dt.toLocaleString('en-US', options); + } + + function parseRecoveryCodeList() { + var recoveryCodes = document.querySelectorAll(".kc-recovery-codes-list li"); + var recoveryCodeList = ""; + + for (var i = 0; i < recoveryCodes.length; i++) { + var recoveryCodeLiElement = recoveryCodes[i].innerText; + recoveryCodeList += recoveryCodeLiElement + "\\r\\n"; + } + + return recoveryCodeList; + } + + function buildDownloadContent() { + var recoveryCodeList = parseRecoveryCodeList(); + var dt = new Date(); + var options = { + month: 'long', + day: 'numeric', + year: 'numeric', + hour: 'numeric', + minute: 'numeric', + timeZoneName: 'short' + }; + + return fileBodyContent = + "${msgStr("recovery-codes-download-file-header")}\\n\\n" + + recoveryCodeList + "\\n" + + "${msgStr("recovery-codes-download-file-description")}\\n\\n" + + "${msgStr("recovery-codes-download-file-date")} " + formatCurrentDateTime(); + } + + function setUpDownloadLinkAndDownload(filename, text) { + var el = document.createElement('a'); + el.setAttribute('href', 'data:text/plain;charset=utf-8,' + encodeURIComponent(text)); + el.setAttribute('download', filename); + el.style.display = 'none'; + document.body.appendChild(el); + el.click(); + document.body.removeChild(el); + } + + function downloadRecoveryCodes() { + setUpDownloadLinkAndDownload('kc-download-recovery-codes.txt', buildDownloadContent()); + } + + var downloadButton = document.getElementById("downloadRecoveryCodes"); + downloadButton && downloadButton.addEventListener("click", downloadRecoveryCodes); + + /* print recovery codes */ + function buildPrintContent() { + var recoveryCodeListHTML = document.getElementById('kc-recovery-codes-list').innerHTML; + var styles = + \`@page { size: auto; margin-top: 0; } + body { width: 480px; } + div { list-style-type: none; font-family: monospace } + p:first-of-type { margin-top: 48px }\`; + + return printFileContent = + "" + + "kc-download-recovery-codes" + + "

${msgStr("recovery-codes-download-file-header")}

" + + "
" + recoveryCodeListHTML + "
" + + "

${msgStr("recovery-codes-download-file-description")}

" + + "

${msgStr("recovery-codes-download-file-date")} " + formatCurrentDateTime() + "

" + + ""; + } + + function printRecoveryCodes() { + var w = window.open(); + w.document.write(buildPrintContent()); + w.print(); + w.close(); + } + + var printButton = document.getElementById("printRecoveryCodes"); + printButton && printButton.addEventListener("click", printRecoveryCodes); + ` + } + ] + }); + + useEffect(() => { + insertScriptTags(); + }, []); + + return ( + + ); +} + +function LogoutOtherSessions(props: { kcClsx: KcClsx; i18n: I18n }) { + const { kcClsx, i18n } = props; + + const { msg } = i18n; + + return ( +
+
+
+ +
+
+
+ ); +} diff --git a/src/login/pages/legacy/LoginRecoveryAuthnCodeInputLegacy.tsx b/src/login/pages/legacy/LoginRecoveryAuthnCodeInputLegacy.tsx new file mode 100644 index 0000000..3cf18b5 --- /dev/null +++ b/src/login/pages/legacy/LoginRecoveryAuthnCodeInputLegacy.tsx @@ -0,0 +1,75 @@ +import { getKcClsx } from "keycloakify/login/lib/kcClsx"; +import type { PageProps } from "keycloakify/login/pages/PageProps"; +import type { KcContext } from "../../KcContext"; +import type { I18n } from "../../i18n"; + +export default function LoginRecoveryAuthnCodeInput(props: PageProps, I18n>) { + const { kcContext, i18n, doUseDefaultCss, Template, classes } = props; + + const { kcClsx } = getKcClsx({ + doUseDefaultCss, + classes + }); + + const { url, messagesPerField, recoveryAuthnCodesInputBean } = kcContext; + + const { msg, msgStr } = i18n; + + return ( + + ); +} diff --git a/src/login/pages/legacy/UpdateEmailLegacy.tsx b/src/login/pages/legacy/UpdateEmailLegacy.tsx new file mode 100644 index 0000000..9c27905 --- /dev/null +++ b/src/login/pages/legacy/UpdateEmailLegacy.tsx @@ -0,0 +1,100 @@ +import { useState } from "react"; +import type { LazyOrNot } from "keycloakify/tools/LazyOrNot"; +import { getKcClsx, type KcClsx } from "keycloakify/login/lib/kcClsx"; +import type { UserProfileFormFieldsProps } from "keycloakify/login/UserProfileFormFieldsProps"; +import type { PageProps } from "keycloakify/login/pages/PageProps"; +import type { KcContext } from "../../KcContext"; +import type { I18n } from "../../i18n"; + +type UpdateEmailProps = PageProps, I18n> & { + UserProfileFormFields: LazyOrNot<(props: UserProfileFormFieldsProps) => JSX.Element>; + doMakeUserConfirmPassword: boolean; +}; + +export default function UpdateEmail(props: UpdateEmailProps) { + const { kcContext, i18n, doUseDefaultCss, Template, classes, UserProfileFormFields, doMakeUserConfirmPassword } = props; + + const { kcClsx } = getKcClsx({ + doUseDefaultCss, + classes + }); + + const { msg, msgStr } = i18n; + + const [isFormSubmittable, setIsFormSubmittable] = useState(false); + + const { url, messagesPerField, isAppInitiatedAction } = kcContext; + + return ( + + ); +} + +function LogoutOtherSessions(props: { kcClsx: KcClsx; i18n: I18n }) { + const { kcClsx, i18n } = props; + + const { msg } = i18n; + + return ( +
+
+
+ +
+
+
+ ); +} diff --git a/src/login/pages/legacy/WebauthnAuthenticateLegacy.tsx b/src/login/pages/legacy/WebauthnAuthenticateLegacy.tsx new file mode 100644 index 0000000..29190c3 --- /dev/null +++ b/src/login/pages/legacy/WebauthnAuthenticateLegacy.tsx @@ -0,0 +1,251 @@ +import { useEffect, Fragment } from "react"; +import { assert } from "keycloakify/tools/assert"; +import { clsx } from "keycloakify/tools/clsx"; +import { useInsertScriptTags } from "keycloakify/tools/useInsertScriptTags"; +import { getKcClsx } from "keycloakify/login/lib/kcClsx"; +import type { PageProps } from "keycloakify/login/pages/PageProps"; +import type { KcContext } from "../../KcContext"; +import type { I18n } from "../../i18n"; + +export default function WebauthnAuthenticate(props: PageProps, I18n>) { + const { kcContext, i18n, doUseDefaultCss, Template, classes } = props; + + const { kcClsx } = getKcClsx({ doUseDefaultCss, classes }); + + const { + url, + isUserIdentified, + challenge, + userVerification, + rpId, + createTimeout, + messagesPerField, + realm, + registrationDisabled, + authenticators, + shouldDisplayAuthenticators + } = kcContext; + + const { msg, msgStr, advancedMsg } = i18n; + + const { insertScriptTags } = useInsertScriptTags({ + componentOrHookName: "WebauthnAuthenticate", + scriptTags: [ + { + type: "text/javascript", + src: `${url.resourcesCommonPath}/node_modules/jquery/dist/jquery.min.js` + }, + { + type: "text/javascript", + src: `${url.resourcesPath}/js/base64url.js` + }, + { + type: "text/javascript", + textContent: ` + + function webAuthnAuthenticate() { + let isUserIdentified = ${isUserIdentified}; + if (!isUserIdentified) { + doAuthenticate([]); + return; + } + checkAllowCredentials(); + } + + function checkAllowCredentials() { + let allowCredentials = []; + let authn_use = document.forms['authn_select'].authn_use_chk; + + if (authn_use !== undefined) { + if (authn_use.length === undefined) { + allowCredentials.push({ + id: base64url.decode(authn_use.value, {loose: true}), + type: 'public-key', + }); + } else { + for (let i = 0; i < authn_use.length; i++) { + allowCredentials.push({ + id: base64url.decode(authn_use[i].value, {loose: true}), + type: 'public-key', + }); + } + } + } + doAuthenticate(allowCredentials); + } + + + function doAuthenticate(allowCredentials) { + + // Check if WebAuthn is supported by this browser + if (!window.PublicKeyCredential) { + $("#error").val("${msgStr("webauthn-unsupported-browser-text")}"); + $("#webauth").submit(); + return; + } + + let challenge = "${challenge}"; + let userVerification = "${userVerification}"; + let rpId = "${rpId}"; + let publicKey = { + rpId : rpId, + challenge: base64url.decode(challenge, { loose: true }) + }; + + let createTimeout = ${createTimeout}; + if (createTimeout !== 0) publicKey.timeout = createTimeout * 1000; + + if (allowCredentials.length) { + publicKey.allowCredentials = allowCredentials; + } + + if (userVerification !== 'not specified') publicKey.userVerification = userVerification; + + navigator.credentials.get({publicKey}) + .then((result) => { + window.result = result; + + let clientDataJSON = result.response.clientDataJSON; + let authenticatorData = result.response.authenticatorData; + let signature = result.response.signature; + + $("#clientDataJSON").val(base64url.encode(new Uint8Array(clientDataJSON), { pad: false })); + $("#authenticatorData").val(base64url.encode(new Uint8Array(authenticatorData), { pad: false })); + $("#signature").val(base64url.encode(new Uint8Array(signature), { pad: false })); + $("#credentialId").val(result.id); + if(result.response.userHandle) { + $("#userHandle").val(base64url.encode(new Uint8Array(result.response.userHandle), { pad: false })); + } + $("#webauth").submit(); + }) + .catch((err) => { + $("#error").val(err); + $("#webauth").submit(); + }) + ; + } + + ` + } + ] + }); + + useEffect(() => { + insertScriptTags(); + }, []); + + return ( + + ); +} diff --git a/src/login/pages/legacy/WebauthnErrorLegacy.tsx b/src/login/pages/legacy/WebauthnErrorLegacy.tsx new file mode 100644 index 0000000..12d5a15 --- /dev/null +++ b/src/login/pages/legacy/WebauthnErrorLegacy.tsx @@ -0,0 +1,62 @@ +import { getKcClsx } from "keycloakify/login/lib/kcClsx"; +import type { PageProps } from "keycloakify/login/pages/PageProps"; +import type { KcContext } from "../../KcContext"; +import type { I18n } from "../../i18n"; + +export default function WebauthnError(props: PageProps, I18n>) { + const { kcContext, i18n, doUseDefaultCss, Template, classes } = props; + + const { url, isAppInitiatedAction } = kcContext; + + const { msg, msgStr } = i18n; + + const { kcClsx } = getKcClsx({ + doUseDefaultCss, + classes + }); + + return ( + + ); +} diff --git a/src/login/pages/legacy/WebauthnRegisterLegacy.tsx b/src/login/pages/legacy/WebauthnRegisterLegacy.tsx new file mode 100644 index 0000000..93ff1c1 --- /dev/null +++ b/src/login/pages/legacy/WebauthnRegisterLegacy.tsx @@ -0,0 +1,276 @@ +import { useEffect } from "react"; +import { assert } from "keycloakify/tools/assert"; +import { getKcClsx, type KcClsx } from "keycloakify/login/lib/kcClsx"; +import { useInsertScriptTags } from "keycloakify/tools/useInsertScriptTags"; +import type { PageProps } from "keycloakify/login/pages/PageProps"; +import type { KcContext } from "../../KcContext"; +import type { I18n } from "../../i18n"; + +export default function WebauthnRegister(props: PageProps, I18n>) { + const { kcContext, i18n, doUseDefaultCss, Template, classes } = props; + + const { kcClsx } = getKcClsx({ doUseDefaultCss, classes }); + + const { + url, + challenge, + userid, + username, + signatureAlgorithms, + rpEntityName, + rpId, + attestationConveyancePreference, + authenticatorAttachment, + requireResidentKey, + userVerificationRequirement, + createTimeout, + excludeCredentialIds, + isSetRetry, + isAppInitiatedAction + } = kcContext; + + const { msg, msgStr } = i18n; + + const { insertScriptTags } = useInsertScriptTags({ + componentOrHookName: "WebauthnRegister", + scriptTags: [ + { + type: "text/javascript", + src: `${url.resourcesCommonPath}/node_modules/jquery/dist/jquery.min.js` + }, + { + type: "text/javascript", + src: `${url.resourcesPath}/js/base64url.js` + }, + { + type: "text/javascript", + textContent: ` + function registerSecurityKey() { + + // Check if WebAuthn is supported by this browser + if (!window.PublicKeyCredential) { + $("#error").val("${msgStr("webauthn-unsupported-browser-text")}"); + $("#register").submit(); + return; + } + + // mandatory parameters + let challenge = "${challenge}"; + let userid = "${userid}"; + let username = "${username}"; + + let signatureAlgorithms =${JSON.stringify(signatureAlgorithms)}; + let pubKeyCredParams = getPubKeyCredParams(signatureAlgorithms); + + let rpEntityName = "${rpEntityName}"; + let rp = {name: rpEntityName}; + + let publicKey = { + challenge: base64url.decode(challenge, {loose: true}), + rp: rp, + user: { + id: base64url.decode(userid, {loose: true}), + name: username, + displayName: username + }, + pubKeyCredParams: pubKeyCredParams, + }; + + // optional parameters + let rpId = "${rpId}"; + publicKey.rp.id = rpId; + + let attestationConveyancePreference = "${attestationConveyancePreference}"; + if (attestationConveyancePreference !== 'not specified') publicKey.attestation = attestationConveyancePreference; + + let authenticatorSelection = {}; + let isAuthenticatorSelectionSpecified = false; + + let authenticatorAttachment = "${authenticatorAttachment}"; + if (authenticatorAttachment !== 'not specified') { + authenticatorSelection.authenticatorAttachment = authenticatorAttachment; + isAuthenticatorSelectionSpecified = true; + } + + let requireResidentKey = "${requireResidentKey}"; + if (requireResidentKey !== 'not specified') { + if (requireResidentKey === 'Yes') + authenticatorSelection.requireResidentKey = true; + else + authenticatorSelection.requireResidentKey = false; + isAuthenticatorSelectionSpecified = true; + } + + let userVerificationRequirement = "${userVerificationRequirement}"; + if (userVerificationRequirement !== 'not specified') { + authenticatorSelection.userVerification = userVerificationRequirement; + isAuthenticatorSelectionSpecified = true; + } + + if (isAuthenticatorSelectionSpecified) publicKey.authenticatorSelection = authenticatorSelection; + + let createTimeout = ${createTimeout}; + if (createTimeout !== 0) publicKey.timeout = createTimeout * 1000; + + let excludeCredentialIds = "${excludeCredentialIds}"; + let excludeCredentials = getExcludeCredentials(excludeCredentialIds); + if (excludeCredentials.length > 0) publicKey.excludeCredentials = excludeCredentials; + + navigator.credentials.create({publicKey}) + .then(function (result) { + window.result = result; + let clientDataJSON = result.response.clientDataJSON; + let attestationObject = result.response.attestationObject; + let publicKeyCredentialId = result.rawId; + + $("#clientDataJSON").val(base64url.encode(new Uint8Array(clientDataJSON), {pad: false})); + $("#attestationObject").val(base64url.encode(new Uint8Array(attestationObject), {pad: false})); + $("#publicKeyCredentialId").val(base64url.encode(new Uint8Array(publicKeyCredentialId), {pad: false})); + + if (typeof result.response.getTransports === "function") { + let transports = result.response.getTransports(); + if (transports) { + $("#transports").val(getTransportsAsString(transports)); + } + } else { + console.log("Your browser is not able to recognize supported transport media for the authenticator."); + } + + let initLabel = "WebAuthn Authenticator (Default Label)"; + let labelResult = window.prompt("Please input your registered authenticator's label", initLabel); + if (labelResult === null) labelResult = initLabel; + $("#authenticatorLabel").val(labelResult); + + $("#register").submit(); + + }) + .catch(function (err) { + $("#error").val(err); + $("#register").submit(); + + }); + } + + function getPubKeyCredParams(signatureAlgorithmsList) { + let pubKeyCredParams = []; + if (signatureAlgorithmsList.length === 0) { + pubKeyCredParams.push({type: "public-key", alg: -7}); + return pubKeyCredParams; + } + + for (let i = 0; i < signatureAlgorithmsList.length; i++) { + pubKeyCredParams.push({ + type: "public-key", + alg: signatureAlgorithmsList[i] + }); + } + return pubKeyCredParams; + } + + function getExcludeCredentials(excludeCredentialIds) { + let excludeCredentials = []; + if (excludeCredentialIds === "") return excludeCredentials; + + let excludeCredentialIdsList = excludeCredentialIds.split(','); + + for (let i = 0; i < excludeCredentialIdsList.length; i++) { + excludeCredentials.push({ + type: "public-key", + id: base64url.decode(excludeCredentialIdsList[i], + {loose: true}) + }); + } + return excludeCredentials; + } + + function getTransportsAsString(transportsList) { + if (transportsList === '' || Array.isArray(transportsList)) return ""; + + let transportsString = ""; + + for (let i = 0; i < transportsList.length; i++) { + transportsString += transportsList[i] + ","; + } + + return transportsString.slice(0, -1); + } + ` + } + ] + }); + + useEffect(() => { + insertScriptTags(); + }, []); + + return ( + + ); +} + +function LogoutOtherSessions(props: { kcClsx: KcClsx; i18n: I18n }) { + const { kcClsx, i18n } = props; + + const { msg } = i18n; + + return ( +
+
+
+ +
+
+
+ ); +}