Skip to content

Commit 0bc5ae5

Browse files
authored
Merge pull request #17 from MichaelBitard/add-totp-to-dsfr
add dsfr to TOTP pages
2 parents a443068 + 38e8e6e commit 0bc5ae5

File tree

5 files changed

+426
-0
lines changed

5 files changed

+426
-0
lines changed

src/login/KcPage.tsx

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import type { ClassKey } from "keycloakify/login";
33
import type { KcContext } from "./KcContext";
44
import { useI18n } from "./i18n";
55
import DefaultPage from "keycloakify/login/DefaultPage";
6+
import LoginOtp from "./pages/LoginOtp";
7+
import LoginConfigTOTP from "./pages/LoginConfigTotp";
68
const Template = lazy(() => import("./Template"));
79
const DefaultTemplate = lazy(() => import("keycloakify/login/Template"));
810
const UserProfileFormFields = lazy(() => import("./UserProfileFormFields"));
@@ -23,6 +25,22 @@ export default function KcPage(props: { kcContext: KcContext }) {
2325
<Suspense>
2426
{(() => {
2527
switch (kcContext.pageId) {
28+
case "login-otp.ftl":
29+
return (
30+
<LoginOtp
31+
{...{ kcContext, i18n, classes }}
32+
Template={Template}
33+
doUseDefaultCss={false}
34+
/>
35+
);
36+
case "login-config-totp.ftl":
37+
return (
38+
<LoginConfigTOTP
39+
{...{ kcContext, i18n, classes }}
40+
Template={Template}
41+
doUseDefaultCss={false}
42+
/>
43+
);
2644
case "login.ftl":
2745
return (
2846
<Login
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import type { Meta, StoryObj } from "@storybook/react";
2+
import { createKcPageStory } from "../KcPageStory";
3+
4+
const { KcPageStory } = createKcPageStory({ pageId: "login-config-totp.ftl" });
5+
6+
const meta = {
7+
title: "login/login-config-totp.ftl",
8+
component: KcPageStory
9+
} satisfies Meta<typeof KcPageStory>;
10+
11+
export default meta;
12+
13+
type Story = StoryObj<typeof meta>;
14+
15+
export const Default: Story = {
16+
render: () => <KcPageStory />
17+
};
18+
19+
export const ManualMode: Story = {
20+
render: () => <KcPageStory kcContext={{ mode: "manual" }} />
21+
};
22+
23+
export const WithAppInitiated: Story = {
24+
render: () => <KcPageStory kcContext={{ isAppInitiatedAction: true }} />
25+
};
26+
export const WithErrors: Story = {
27+
render: () => (
28+
<KcPageStory
29+
kcContext={{
30+
messagesPerField: {
31+
// NOTE: The other functions of messagesPerField are derived from get() and
32+
// existsError() so they are the only ones that need to mock.
33+
existsError: (fieldName: string, ...otherFieldNames: string[]) => {
34+
const fieldNames = [fieldName, ...otherFieldNames];
35+
return fieldNames.includes("totp") || fieldNames.includes("userLabel");
36+
},
37+
get: (fieldName: string) => {
38+
if (fieldName === "totp") {
39+
return "Invalid code.";
40+
}
41+
if (fieldName === "userLabel") return "Aleardy used name";
42+
}
43+
}
44+
}}
45+
/>
46+
)
47+
};
Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
import { fr } from "@codegouvfr/react-dsfr";
2+
import { Button } from "@codegouvfr/react-dsfr/Button";
3+
import { Input } from "@codegouvfr/react-dsfr/Input";
4+
import { KcClsx, getKcClsx } from "keycloakify/login/lib/kcClsx";
5+
import type { PageProps } from "keycloakify/login/pages/PageProps";
6+
import type { KcContext } from "../KcContext";
7+
import type { I18n } from "../i18n";
8+
import { useState } from "react";
9+
10+
export default function LoginConfigTOTP(props: PageProps<Extract<KcContext, { pageId: "login-config-totp.ftl" }>, I18n>) {
11+
const { kcContext, i18n, doUseDefaultCss, Template, classes } = props;
12+
13+
const { kcClsx } = getKcClsx({
14+
doUseDefaultCss,
15+
classes
16+
});
17+
18+
const { url, messagesPerField, totp, mode, isAppInitiatedAction } = kcContext;
19+
20+
const { msg, msgStr, advancedMsg } = i18n;
21+
const [isLoginButtonDisabled, setIsLoginButtonDisabled] = useState(false);
22+
23+
return (
24+
<Template
25+
kcContext={kcContext}
26+
i18n={i18n}
27+
doUseDefaultCss={doUseDefaultCss}
28+
classes={classes}
29+
displayMessage={!messagesPerField.existsError("totp")}
30+
headerNode={msg("loginAccountTitle")}
31+
>
32+
<>
33+
<ol id="kc-totp-settings">
34+
<li>
35+
<p>{msg("loginTotpStep1")}</p>
36+
<ul id="kc-totp-supported-apps">
37+
{totp.supportedApplications.map(app => (
38+
<li key={app}>{advancedMsg(app)}</li>
39+
))}
40+
</ul>
41+
</li>
42+
43+
{mode === "manual" ? (
44+
<>
45+
<li>
46+
<p>{msg("loginTotpManualStep2")}</p>
47+
<p>
48+
<span id="kc-totp-secret-key">{totp.totpSecretEncoded}</span>
49+
</p>
50+
<p>
51+
<a href={totp.qrUrl} id="mode-barcode">
52+
{msg("loginTotpScanBarcode")}
53+
</a>
54+
</p>
55+
</li>
56+
<li>
57+
<p>{msg("loginTotpManualStep3")}</p>
58+
<p>
59+
<ul>
60+
<li id="kc-totp-type">
61+
{msg("loginTotpType")}: {msg(`loginTotp.${totp.policy.type}`)}
62+
</li>
63+
<li id="kc-totp-algorithm">
64+
{msg("loginTotpAlgorithm")}: {totp.policy.getAlgorithmKey()}
65+
</li>
66+
<li id="kc-totp-digits">
67+
{msg("loginTotpDigits")}: {totp.policy.digits}
68+
</li>
69+
{totp.policy.type === "totp" && (
70+
<li id="kc-totp-period">
71+
{msg("loginTotpInterval")}: {totp.policy.period}
72+
</li>
73+
)}
74+
{totp.policy.type === "hotp" && (
75+
<li id="kc-totp-counter">
76+
{msg("loginTotpCounter")}: {totp.policy.initialCounter}
77+
</li>
78+
)}
79+
</ul>
80+
</p>
81+
</li>
82+
</>
83+
) : (
84+
<li>
85+
<p>{msg("loginTotpStep2")}</p>
86+
<img id="kc-totp-secret-qr-code" src={`data:image/png;base64, ${totp.totpSecretQrCode}`} alt="Figure: Barcode" />
87+
<br />
88+
<p>
89+
<a href={totp.manualUrl} id="mode-manual">
90+
{msg("loginTotpUnableToScan")}
91+
</a>
92+
</p>
93+
</li>
94+
)}
95+
<li>
96+
<p>{msg("loginTotpStep3")}</p>
97+
<p>{msg("loginTotpStep3DeviceName")}</p>
98+
</li>
99+
</ol>
100+
<form
101+
action={url.loginAction}
102+
className={kcClsx("kcFormClass")}
103+
id="kc-totp-settings-form"
104+
onSubmit={() => {
105+
setIsLoginButtonDisabled(true);
106+
return true;
107+
}}
108+
method="post"
109+
>
110+
<Input
111+
label={msg("authenticatorCode")}
112+
state={messagesPerField.existsError("totp") ? "error" : "default"}
113+
stateRelatedMessage={messagesPerField.getFirstError("totp")}
114+
nativeInputProps={{
115+
name: "totp",
116+
required: true,
117+
autoFocus: true,
118+
defaultValue: "",
119+
tabIndex: 1
120+
}}
121+
/>
122+
<input type="hidden" id="totpSecret" name="totpSecret" value={totp.totpSecret} />
123+
{mode && <input type="hidden" id="mode" name="mode" value={mode} />}
124+
125+
<Input
126+
label={msg("loginTotpDeviceName")}
127+
state={messagesPerField.existsError("userLabel") ? "error" : "default"}
128+
stateRelatedMessage={messagesPerField.getFirstError("userLabel")}
129+
nativeInputProps={{
130+
required: (totp.otpCredentials ?? []).length > 1,
131+
name: "userLabel",
132+
autoFocus: true,
133+
defaultValue: "",
134+
tabIndex: 2
135+
}}
136+
/>
137+
<div className={kcClsx("kcFormGroupClass")}>
138+
<LogoutOtherSessions kcClsx={kcClsx} i18n={i18n} />
139+
</div>
140+
{isAppInitiatedAction ? (
141+
<ul className="fr-btns-group fr-btns-group--inline-lg">
142+
<li>
143+
<Button
144+
className={fr.cx("fr-my-2w")}
145+
type="submit"
146+
disabled={isLoginButtonDisabled}
147+
nativeButtonProps={{
148+
tabIndex: 3,
149+
id: "saveTOTPBtn"
150+
}}
151+
>
152+
{msgStr("doSubmit")}
153+
</Button>
154+
</li>
155+
<li>
156+
<Button
157+
className={fr.cx("fr-my-2w")}
158+
type="submit"
159+
disabled={isLoginButtonDisabled}
160+
value="true"
161+
nativeButtonProps={{
162+
tabIndex: 4,
163+
id: "cancelTOTPBtn",
164+
name: "cancel-aia"
165+
}}
166+
>
167+
{msgStr("doCancel")}
168+
</Button>
169+
</li>
170+
</ul>
171+
) : (
172+
<Button
173+
className={fr.cx("fr-my-2w")}
174+
type="submit"
175+
disabled={isLoginButtonDisabled}
176+
nativeButtonProps={{
177+
tabIndex: 3,
178+
id: "saveTOTPBtn"
179+
}}
180+
>
181+
{msgStr("doSubmit")}
182+
</Button>
183+
)}
184+
</form>
185+
</>
186+
</Template>
187+
);
188+
}
189+
190+
function LogoutOtherSessions(props: { kcClsx: KcClsx; i18n: I18n }) {
191+
const { kcClsx, i18n } = props;
192+
193+
const { msg } = i18n;
194+
195+
return (
196+
<div id="kc-form-options" className={kcClsx("kcFormOptionsClass")}>
197+
<div className={kcClsx("kcFormOptionsWrapperClass")}>
198+
<div className={fr.cx("fr-checkbox-group", "fr-checkbox-group--sm")}>
199+
<input id="logout-sessions" tabIndex={5} name="logout-sessions" type="checkbox" defaultChecked={true} />{" "}
200+
<label htmlFor="logout-sessions">{msg("logoutOtherSessions")}</label>
201+
</div>
202+
</div>
203+
</div>
204+
);
205+
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import type { Meta, StoryObj } from "@storybook/react";
2+
import { createKcPageStory } from "../KcPageStory";
3+
4+
const { KcPageStory } = createKcPageStory({ pageId: "login-otp.ftl" });
5+
6+
const meta = {
7+
title: "login/login-otp.ftl",
8+
component: KcPageStory
9+
} satisfies Meta<typeof KcPageStory>;
10+
11+
export default meta;
12+
13+
type Story = StoryObj<typeof meta>;
14+
15+
export const Default: Story = {
16+
render: () => <KcPageStory />
17+
};
18+
19+
export const OnlyOneOtp: Story = {
20+
render: () => <KcPageStory kcContext={{ otpLogin: { userOtpCredentials: [{ id: "id1", userLabel: "label" }] } }} />
21+
};
22+
23+
export const WithErrors: Story = {
24+
render: () => (
25+
<KcPageStory
26+
kcContext={{
27+
otpLogin: {
28+
selectedCredentialId: "id1"
29+
},
30+
messagesPerField: {
31+
// NOTE: The other functions of messagesPerField are derived from get() and
32+
// existsError() so they are the only ones that need to mock.
33+
existsError: (fieldName: string, ...otherFieldNames: string[]) => {
34+
const fieldNames = [fieldName, ...otherFieldNames];
35+
return fieldNames.includes("totp");
36+
},
37+
get: (fieldName: string) => {
38+
if (fieldName === "totp") {
39+
return "Invalid code.";
40+
}
41+
return "";
42+
}
43+
}
44+
}}
45+
/>
46+
)
47+
};

0 commit comments

Comments
 (0)