Skip to content

Commit

Permalink
Merge pull request #17 from MichaelBitard/add-totp-to-dsfr
Browse files Browse the repository at this point in the history
add dsfr to TOTP pages
  • Loading branch information
garronej authored Jan 16, 2025
2 parents a443068 + 38e8e6e commit 0bc5ae5
Show file tree
Hide file tree
Showing 5 changed files with 426 additions and 0 deletions.
18 changes: 18 additions & 0 deletions src/login/KcPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import type { ClassKey } from "keycloakify/login";
import type { KcContext } from "./KcContext";
import { useI18n } from "./i18n";
import DefaultPage from "keycloakify/login/DefaultPage";
import LoginOtp from "./pages/LoginOtp";
import LoginConfigTOTP from "./pages/LoginConfigTotp";
const Template = lazy(() => import("./Template"));
const DefaultTemplate = lazy(() => import("keycloakify/login/Template"));
const UserProfileFormFields = lazy(() => import("./UserProfileFormFields"));
Expand All @@ -23,6 +25,22 @@ export default function KcPage(props: { kcContext: KcContext }) {
<Suspense>
{(() => {
switch (kcContext.pageId) {
case "login-otp.ftl":
return (
<LoginOtp
{...{ kcContext, i18n, classes }}
Template={Template}
doUseDefaultCss={false}
/>
);
case "login-config-totp.ftl":
return (
<LoginConfigTOTP
{...{ kcContext, i18n, classes }}
Template={Template}
doUseDefaultCss={false}
/>
);
case "login.ftl":
return (
<Login
Expand Down
47 changes: 47 additions & 0 deletions src/login/pages/LoginConfigTotp.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import type { Meta, StoryObj } from "@storybook/react";
import { createKcPageStory } from "../KcPageStory";

const { KcPageStory } = createKcPageStory({ pageId: "login-config-totp.ftl" });

const meta = {
title: "login/login-config-totp.ftl",
component: KcPageStory
} satisfies Meta<typeof KcPageStory>;

export default meta;

type Story = StoryObj<typeof meta>;

export const Default: Story = {
render: () => <KcPageStory />
};

export const ManualMode: Story = {
render: () => <KcPageStory kcContext={{ mode: "manual" }} />
};

export const WithAppInitiated: Story = {
render: () => <KcPageStory kcContext={{ isAppInitiatedAction: true }} />
};
export const WithErrors: Story = {
render: () => (
<KcPageStory
kcContext={{
messagesPerField: {
// NOTE: The other functions of messagesPerField are derived from get() and
// existsError() so they are the only ones that need to mock.
existsError: (fieldName: string, ...otherFieldNames: string[]) => {
const fieldNames = [fieldName, ...otherFieldNames];
return fieldNames.includes("totp") || fieldNames.includes("userLabel");
},
get: (fieldName: string) => {
if (fieldName === "totp") {
return "Invalid code.";
}
if (fieldName === "userLabel") return "Aleardy used name";
}
}
}}
/>
)
};
205 changes: 205 additions & 0 deletions src/login/pages/LoginConfigTotp.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
import { fr } from "@codegouvfr/react-dsfr";
import { Button } from "@codegouvfr/react-dsfr/Button";
import { Input } from "@codegouvfr/react-dsfr/Input";
import { KcClsx, 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 { useState } from "react";

export default function LoginConfigTOTP(props: PageProps<Extract<KcContext, { pageId: "login-config-totp.ftl" }>, I18n>) {
const { kcContext, i18n, doUseDefaultCss, Template, classes } = props;

const { kcClsx } = getKcClsx({
doUseDefaultCss,
classes
});

const { url, messagesPerField, totp, mode, isAppInitiatedAction } = kcContext;

const { msg, msgStr, advancedMsg } = i18n;
const [isLoginButtonDisabled, setIsLoginButtonDisabled] = useState(false);

return (
<Template
kcContext={kcContext}
i18n={i18n}
doUseDefaultCss={doUseDefaultCss}
classes={classes}
displayMessage={!messagesPerField.existsError("totp")}
headerNode={msg("loginAccountTitle")}
>
<>
<ol id="kc-totp-settings">
<li>
<p>{msg("loginTotpStep1")}</p>
<ul id="kc-totp-supported-apps">
{totp.supportedApplications.map(app => (
<li key={app}>{advancedMsg(app)}</li>
))}
</ul>
</li>

{mode === "manual" ? (
<>
<li>
<p>{msg("loginTotpManualStep2")}</p>
<p>
<span id="kc-totp-secret-key">{totp.totpSecretEncoded}</span>
</p>
<p>
<a href={totp.qrUrl} id="mode-barcode">
{msg("loginTotpScanBarcode")}
</a>
</p>
</li>
<li>
<p>{msg("loginTotpManualStep3")}</p>
<p>
<ul>
<li id="kc-totp-type">
{msg("loginTotpType")}: {msg(`loginTotp.${totp.policy.type}`)}
</li>
<li id="kc-totp-algorithm">
{msg("loginTotpAlgorithm")}: {totp.policy.getAlgorithmKey()}
</li>
<li id="kc-totp-digits">
{msg("loginTotpDigits")}: {totp.policy.digits}
</li>
{totp.policy.type === "totp" && (
<li id="kc-totp-period">
{msg("loginTotpInterval")}: {totp.policy.period}
</li>
)}
{totp.policy.type === "hotp" && (
<li id="kc-totp-counter">
{msg("loginTotpCounter")}: {totp.policy.initialCounter}
</li>
)}
</ul>
</p>
</li>
</>
) : (
<li>
<p>{msg("loginTotpStep2")}</p>
<img id="kc-totp-secret-qr-code" src={`data:image/png;base64, ${totp.totpSecretQrCode}`} alt="Figure: Barcode" />
<br />
<p>
<a href={totp.manualUrl} id="mode-manual">
{msg("loginTotpUnableToScan")}
</a>
</p>
</li>
)}
<li>
<p>{msg("loginTotpStep3")}</p>
<p>{msg("loginTotpStep3DeviceName")}</p>
</li>
</ol>
<form
action={url.loginAction}
className={kcClsx("kcFormClass")}
id="kc-totp-settings-form"
onSubmit={() => {
setIsLoginButtonDisabled(true);
return true;
}}
method="post"
>
<Input
label={msg("authenticatorCode")}
state={messagesPerField.existsError("totp") ? "error" : "default"}
stateRelatedMessage={messagesPerField.getFirstError("totp")}
nativeInputProps={{
name: "totp",
required: true,
autoFocus: true,
defaultValue: "",
tabIndex: 1
}}
/>
<input type="hidden" id="totpSecret" name="totpSecret" value={totp.totpSecret} />
{mode && <input type="hidden" id="mode" name="mode" value={mode} />}

<Input
label={msg("loginTotpDeviceName")}
state={messagesPerField.existsError("userLabel") ? "error" : "default"}
stateRelatedMessage={messagesPerField.getFirstError("userLabel")}
nativeInputProps={{
required: (totp.otpCredentials ?? []).length > 1,
name: "userLabel",
autoFocus: true,
defaultValue: "",
tabIndex: 2
}}
/>
<div className={kcClsx("kcFormGroupClass")}>
<LogoutOtherSessions kcClsx={kcClsx} i18n={i18n} />
</div>
{isAppInitiatedAction ? (
<ul className="fr-btns-group fr-btns-group--inline-lg">
<li>
<Button
className={fr.cx("fr-my-2w")}
type="submit"
disabled={isLoginButtonDisabled}
nativeButtonProps={{
tabIndex: 3,
id: "saveTOTPBtn"
}}
>
{msgStr("doSubmit")}
</Button>
</li>
<li>
<Button
className={fr.cx("fr-my-2w")}
type="submit"
disabled={isLoginButtonDisabled}
value="true"
nativeButtonProps={{
tabIndex: 4,
id: "cancelTOTPBtn",
name: "cancel-aia"
}}
>
{msgStr("doCancel")}
</Button>
</li>
</ul>
) : (
<Button
className={fr.cx("fr-my-2w")}
type="submit"
disabled={isLoginButtonDisabled}
nativeButtonProps={{
tabIndex: 3,
id: "saveTOTPBtn"
}}
>
{msgStr("doSubmit")}
</Button>
)}
</form>
</>
</Template>
);
}

function LogoutOtherSessions(props: { kcClsx: KcClsx; i18n: I18n }) {
const { kcClsx, i18n } = props;

const { msg } = i18n;

return (
<div id="kc-form-options" className={kcClsx("kcFormOptionsClass")}>
<div className={kcClsx("kcFormOptionsWrapperClass")}>
<div className={fr.cx("fr-checkbox-group", "fr-checkbox-group--sm")}>
<input id="logout-sessions" tabIndex={5} name="logout-sessions" type="checkbox" defaultChecked={true} />{" "}
<label htmlFor="logout-sessions">{msg("logoutOtherSessions")}</label>
</div>
</div>
</div>
);
}
47 changes: 47 additions & 0 deletions src/login/pages/LoginOtp.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import type { Meta, StoryObj } from "@storybook/react";
import { createKcPageStory } from "../KcPageStory";

const { KcPageStory } = createKcPageStory({ pageId: "login-otp.ftl" });

const meta = {
title: "login/login-otp.ftl",
component: KcPageStory
} satisfies Meta<typeof KcPageStory>;

export default meta;

type Story = StoryObj<typeof meta>;

export const Default: Story = {
render: () => <KcPageStory />
};

export const OnlyOneOtp: Story = {
render: () => <KcPageStory kcContext={{ otpLogin: { userOtpCredentials: [{ id: "id1", userLabel: "label" }] } }} />
};

export const WithErrors: Story = {
render: () => (
<KcPageStory
kcContext={{
otpLogin: {
selectedCredentialId: "id1"
},
messagesPerField: {
// NOTE: The other functions of messagesPerField are derived from get() and
// existsError() so they are the only ones that need to mock.
existsError: (fieldName: string, ...otherFieldNames: string[]) => {
const fieldNames = [fieldName, ...otherFieldNames];
return fieldNames.includes("totp");
},
get: (fieldName: string) => {
if (fieldName === "totp") {
return "Invalid code.";
}
return "";
}
}
}}
/>
)
};
Loading

0 comments on commit 0bc5ae5

Please sign in to comment.