feat: translate all the things

feat-otp-verification
James Elliott 2023-02-12 21:49:55 +11:00
parent 7e56cf2d15
commit 515309c10e
No known key found for this signature in database
GPG Key ID: 0F1C4A096E857E49
35 changed files with 517 additions and 327 deletions

View File

@ -774,5 +774,5 @@ Layouts:
)
const (
fmtLogServerListening = "Server is listening for %s connections on '%s' path '%s'"
fmtLogServerListening = "Listening for %s connections on '%s' path '%s'"
)

View File

@ -8,6 +8,7 @@
"Automatically refresh these permissions without user interaction": "Automatically refresh these permissions without user interaction",
"Cancel": "Cancel",
"Client ID": "Client ID: {{client_id}}",
"Close": "Close",
"Consent Request": "Consent Request",
"Contact your administrator to register a device": "Contact your administrator to register a device.",
"Could not obtain user settings": "Could not obtain user settings",
@ -50,8 +51,8 @@
"Reset password?": "Reset password?",
"Reset": "Reset",
"Scan QR Code": "Scan QR Code",
"Scope": "Scope {{name}}",
"Secret": "Secret",
"Security Key - WebAuthN": "Security Key - WebAuthN",
"Select a Device": "Select a Device",
"Sign in": "Sign in",
"Sign out": "Sign out",
@ -67,6 +68,7 @@
"Time-based One-Time Password": "Time-based One-Time Password",
"Use OpenID to verify your identity": "Use OpenID to verify your identity",
"Username": "Username",
"Webauthn - Security Key": "Webauthn - Security Key",
"You must open the link from the same device and browser that initiated the registration process": "You must open the link from the same device and browser that initiated the registration process",
"You must view and accept the Privacy Policy before using": "You must view and accept the <0>Privacy Policy</0> before using",
"You're being signed out and redirected": "You're being signed out and redirected",

View File

@ -1,7 +1,9 @@
{
"Actions": "Actions",
"Add": "Add",
"Add new Security Key": "Add new Security Key",
"Add Credential": "Add Credential",
"Added": "Added {{when, datetime}}",
"Are you sure you want to remove the Webauthn credential from from your account": "Are you sure you want to remove the Webauthn credential {{description}} from your account?",
"Attestation Type": "Attestation Type",
"Authenticator Attestation GUID": "Authenticator Attestation GUID",
"Cancel": "Cancel",
@ -9,21 +11,39 @@
"Created": "Created",
"Delete": "Delete",
"Details": "Details",
"Display extended information for this Webauthn credential": "Display extended information for this Webauthn credential",
"Edit": "Edit",
"Edit information for this Webauthn credential": "Edit information for this Webauthn credential",
"Edit Webauthn Credential": "Edit Webauthn Credential",
"Enabled": "Enabled",
"Last Used": "Last Used",
"Enter a new name for this Webauthn credential": "Enter a new name for this Webauthn credential:",
"Extended Webauthn credential information for security key": "Extended Webauthn credential information for security key {{description}}",
"Identifier": "Identifier",
"Last Used": "Last Used {{when, datetime}}",
"Manage your security keys": "Manage your security keys",
"Name": "Name",
"No": "No",
"No Registered Webauthn Credentials": "No Registered Webauthn Credentials",
"Overview": "Overview",
"Provide the details for the new security key": "Provide the details for the new security key",
"Register Webauthn Credential (Security Key)": "Register Webauthn Credential (Security Key)",
"Relying Party ID": "Relying Party ID",
"Remove": "Remove",
"Remove this Webauthn credential": "Remove this Webauthn credential",
"Remove Webauthn Credential": "Remove Webauthn Credential",
"Settings": "Settings",
"Show Details": "Show Details",
"Successfully deleted the Webauthn credential": "Successfully deleted the Webauthn credential",
"Successfully updated the Webauthn credential": "Successfully updated the Webauthn credential",
"There was a problem deleting the Webauthn credential": "There was a problem deleting the Webauthn credential",
"There was a problem updating the Webauthn credential": "There was a problem updating the Webauthn credential",
"Transports": "Transports",
"Two-Factor Authentication": "Two-Factor Authentication",
"Usage Count": "Usage Count",
"Webauthn Credential Identifier": "Credential Identifier: {{id}}",
"Webauthn Public Key": "Public Key: {{key}}",
"Yes": "Yes"
"Webauthn Credential Details": "Webauthn Credential Details",
"Webauthn Credentials": "Webauthn Credentials",
"Yes": "Yes",
"You must have a higher authentication level to delete Webauthn credentials": "You must have a higher authentication level to delete Webauthn credentials",
"You must be elevated to delete Webauthn credentials": "You must be elevated to delete Webauthn credentials",
"You must have a higher authentication level to update Webauthn credentials": "You must have a higher authentication level to update Webauthn credentials",
"You must be elevated to update Webauthn credentials": "You must be elevated to update Webauthn credentials"
}

View File

@ -37,6 +37,7 @@
"axios": "1.3.2",
"broadcast-channel": "4.20.2",
"classnames": "2.3.2",
"date-fns": "2.29.3",
"i18next": "22.4.9",
"i18next-browser-languagedetector": "7.0.1",
"i18next-http-backend": "2.1.1",

View File

@ -30,6 +30,7 @@ specifiers:
axios: 1.3.2
broadcast-channel: 4.20.2
classnames: 2.3.2
date-fns: 2.29.3
esbuild: 0.17.7
esbuild-jest: 0.5.0
eslint: 8.34.0
@ -83,6 +84,7 @@ dependencies:
axios: 1.3.2
broadcast-channel: 4.20.2
classnames: 2.3.2
date-fns: 2.29.3
i18next: 22.4.9
i18next-browser-languagedetector: 7.0.1
i18next-http-backend: 2.1.1
@ -5003,6 +5005,11 @@ packages:
whatwg-url: 11.0.0
dev: true
/date-fns/2.29.3:
resolution: {integrity: sha512-dDCnyH2WnnKusqvZZ6+jA1O51Ibt8ZMRNkDZdyAyK4YfbDwa/cEmuztzG5pk6hqlp9aSBPYcjOlktquahGwGeA==}
engines: {node: '>=0.11'}
dev: false
/debug/2.6.9:
resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==}
peerDependencies:
@ -5396,6 +5403,7 @@ packages:
/eslint-config-prettier/8.6.0_eslint@8.34.0:
resolution: {integrity: sha512-bAF0eLpLVqP5oEVUFKpMA+NnRFICwn9X8B5jrR9FcqnYBuPbqWEjTEspPWMj5ye6czoSLDweCzSo3Ko7gGrZaA==}
hasBin: true
peerDependencies:
eslint: '>=7.0.0'
dependencies:
@ -6860,6 +6868,7 @@ packages:
/jest-cli/29.4.2_@types+node@18.13.0:
resolution: {integrity: sha512-b+eGUtXq/K2v7SH3QcJvFvaUaCDS1/YAZBYz0m28Q/Ppyr+1qNaHmVYikOrbHVbZqYQs2IeI3p76uy6BWbXq8Q==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
hasBin: true
peerDependencies:
node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0
peerDependenciesMeta:
@ -7375,6 +7384,7 @@ packages:
/jest/29.4.2_@types+node@18.13.0:
resolution: {integrity: sha512-+5hLd260vNIHu+7ZgMIooSpKl7Jp5pHKb51e73AJU3owd5dEo/RfVwHbA/na3C/eozrt3hJOLGf96c7EWwIAzg==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
hasBin: true
peerDependencies:
node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0
peerDependenciesMeta:
@ -9229,6 +9239,7 @@ packages:
/ts-node/10.9.0_4bewfcp2iebiwuold25d6rgcsy:
resolution: {integrity: sha512-bunW18GUyaCSYRev4DPf4SQpom3pWH29wKl0sDk5zE7ze19RImEVhCW7K4v3hHKkUyfWotU08ToE2RS+Y49aug==}
hasBin: true
peerDependencies:
'@swc/core': '>=1.2.50'
'@swc/wasm': '>=1.2.50'
@ -9260,6 +9271,7 @@ packages:
/tsconfck/2.0.1_typescript@4.9.5:
resolution: {integrity: sha512-/ipap2eecmVBmBlsQLBRbUmUNFwNJV/z2E+X0FPtHNjPwroMZQ7m39RMaCywlCulBheYXgMdUlWDd9rzxwMA0Q==}
engines: {node: ^14.13.1 || ^16 || >=18, pnpm: ^7.0.1}
hasBin: true
peerDependencies:
typescript: ^4.3.5
peerDependenciesMeta:
@ -9422,6 +9434,7 @@ packages:
/update-browserslist-db/1.0.10_browserslist@4.21.4:
resolution: {integrity: sha512-OztqDenkfFkbSG+tRxBeAnCVPckDBcvibKd35yDONx6OU8N7sqgwc7rCbkJ/WcYtVRZ4ba68d6byhC21GFh7sQ==}
hasBin: true
peerDependencies:
browserslist: '>= 4.21.0'
dependencies:
@ -9523,6 +9536,7 @@ packages:
/vite/4.1.1_@types+node@18.13.0:
resolution: {integrity: sha512-LM9WWea8vsxhr782r9ntg+bhSFS06FJgCvvB0+8hf8UWtvaiDagKYWXndjfX6kGl74keHJUcpzrQliDXZlF5yg==}
engines: {node: ^14.18.0 || >=16.0.0}
hasBin: true
peerDependencies:
'@types/node': '>= 14'
less: '*'

View File

@ -12,7 +12,6 @@ import {
IndexRoute,
LogoutRoute,
RegisterOneTimePasswordRoute,
RegisterWebauthnRoute,
ResetPasswordStep1Route,
ResetPasswordStep2Route,
SettingsRoute,
@ -29,7 +28,6 @@ import {
getTheme,
} from "@utils/Configuration";
import RegisterOneTimePassword from "@views/DeviceRegistration/RegisterOneTimePassword";
import RegisterWebauthn from "@views/DeviceRegistration/RegisterWebauthn";
import BaseLoadingPage from "@views/LoadingPage/BaseLoadingPage";
import ConsentView from "@views/LoginPortal/ConsentView/ConsentView";
import LoginPortal from "@views/LoginPortal/LoginPortal";
@ -91,7 +89,6 @@ const App: React.FC<Props> = (props: Props) => {
<Routes>
<Route path={ResetPasswordStep1Route} element={<ResetPasswordStep1 />} />
<Route path={ResetPasswordStep2Route} element={<ResetPasswordStep2 />} />
<Route path={RegisterWebauthnRoute} element={<RegisterWebauthn />} />
<Route path={RegisterOneTimePasswordRoute} element={<RegisterOneTimePassword />} />
<Route path={LogoutRoute} element={<SignOut />} />
<Route path={ConsentRoute} element={<ConsentView />} />

View File

@ -14,6 +14,7 @@ const url = "https://www.authelia.com";
const Brand = function (props: Props) {
const { t: translate } = useTranslation();
const styles = useStyles();
const privacyEnabled = getPrivacyPolicyEnabled();

View File

@ -19,7 +19,7 @@ const PasswordMeter = function (props: Props) {
const [progressColor] = useState(["#D32F2F", "#FF5722", "#FFEB3B", "#AFB42B", "#62D32F"]);
const [passwordScore, setPasswordScore] = useState(0);
const [maxScores, setMaxScores] = useState(0);
const [feedback, setFeedback] = useState("");
const [feedback, setFeedback] = useState<string | null>(null);
useEffect(() => {
const password = props.value;
@ -114,7 +114,7 @@ const PasswordMeter = function (props: Props) {
return (
<Box className={styles.progressContainer}>
<Box title={feedback} className={classnames(styles.progressBar)} />
<Box title={feedback === null ? "" : feedback} className={classnames(styles.progressBar)} />
</Box>
);
};

View File

@ -6,10 +6,11 @@ import { usePersistentStorageValue } from "@hooks/PersistentStorage";
import { getPrivacyPolicyEnabled, getPrivacyPolicyRequireAccept } from "@utils/Configuration";
const PrivacyPolicyDrawer = function (props: DrawerProps) {
const { t: translate } = useTranslation();
const privacyEnabled = getPrivacyPolicyEnabled();
const privacyRequireAccept = getPrivacyPolicyRequireAccept();
const [accepted, setAccepted] = usePersistentStorageValue<boolean>("privacy-policy-accepted", false);
const { t: translate } = useTranslation();
return privacyEnabled && privacyRequireAccept && !accepted ? (
<Drawer {...props} anchor="bottom" open={!accepted}>

View File

@ -6,10 +6,10 @@ import { useTranslation } from "react-i18next";
import { getPrivacyPolicyURL } from "@utils/Configuration";
const PrivacyPolicyLink = function (props: LinkProps) {
const hrefPrivacyPolicy = getPrivacyPolicyURL();
const { t: translate } = useTranslation();
const hrefPrivacyPolicy = getPrivacyPolicyURL();
return (
<Fragment>
<Link {...props} href={hrefPrivacyPolicy} target="_blank" rel="noopener" underline="hover">

View File

@ -0,0 +1,39 @@
import React, { useEffect } from "react";
import { Box, Theme, useTheme } from "@mui/material";
import makeStyles from "@mui/styles/makeStyles";
import FingerTouchIcon from "@components/FingerTouchIcon";
import LinearProgressBar from "@components/LinearProgressBar";
import { useTimer } from "@hooks/Timer";
import IconWithContext from "@views/LoginPortal/SecondFactor/IconWithContext";
interface Props {
timeout: number;
}
export default function WebauthnRegisterIcon(props: Props) {
const theme = useTheme();
const [timerPercent, triggerTimer] = useTimer(props.timeout);
const styles = makeStyles((theme: Theme) => ({
icon: {
display: "inline-block",
},
progressBar: {
marginTop: theme.spacing(),
},
}))();
useEffect(() => {
triggerTimer();
}, [triggerTimer]);
return (
<Box className={styles.icon} sx={{ minHeight: 101 }}>
<IconWithContext icon={<FingerTouchIcon size={64} animated strong />}>
<LinearProgressBar value={timerPercent} className={styles.progressBar} height={theme.spacing(2)} />
</IconWithContext>
</Box>
);
}

View File

@ -9,7 +9,6 @@ export const SecondFactorPushSubRoute: string = "/push-notification";
export const ResetPasswordStep1Route: string = "/reset-password/step1";
export const ResetPasswordStep2Route: string = "/reset-password/step2";
export const RegisterWebauthnRoute: string = "/webauthn/register";
export const RegisterOneTimePasswordRoute: string = "/one-time-password/register";
export const LogoutRoute: string = "/logout";

View File

@ -16,18 +16,19 @@ import { getLogoOverride } from "@utils/Configuration";
export interface Props {
id?: string;
children?: ReactNode;
title?: string;
titleTooltip?: string;
subtitle?: string;
subtitleTooltip?: string;
title?: string | null;
titleTooltip?: string | null;
subtitle?: string | null;
subtitleTooltip?: string | null;
showBrand?: boolean;
showSettings?: boolean;
}
const LoginLayout = function (props: Props) {
const { t: translate } = useTranslation();
const navigate = useNavigate();
const styles = useStyles();
const { t: translate } = useTranslation();
const logo = getLogoOverride() ? (
<img src="./static/media/logo.png" alt="Logo" className={styles.icon} />
@ -82,7 +83,7 @@ const LoginLayout = function (props: Props) {
<TypographyWithTooltip
variant={"h5"}
value={props.title}
tooltip={props.titleTooltip}
tooltip={props.titleTooltip !== null ? props.titleTooltip : undefined}
/>
</Grid>
) : null}
@ -91,7 +92,7 @@ const LoginLayout = function (props: Props) {
<TypographyWithTooltip
variant={"h6"}
value={props.subtitle}
tooltip={props.subtitleTooltip}
tooltip={props.subtitleTooltip !== null ? props.subtitleTooltip : undefined}
/>
</Grid>
) : null}

View File

@ -33,6 +33,7 @@ const defaultDrawerWidth = 240;
const SettingsLayout = function (props: Props) {
const { t: translate } = useTranslation("settings");
const navigate = useRouterNavigate();
useEffect(() => {
@ -65,7 +66,7 @@ const SettingsLayout = function (props: Props) {
navigate(IndexRoute);
}}
>
{"Close"}
{translate("Close")}
</Button>
</Toolbar>
</AppBar>
@ -114,7 +115,7 @@ const SettingsMenuItem = function (props: SettingsMenuItemProps) {
const navigate = useRouterNavigate();
return (
<ListItem disablePadding onClick={selected ? () => console.log("selected") : () => navigate(props.pathname)}>
<ListItem disablePadding onClick={!selected ? () => navigate(props.pathname) : undefined}>
<ListItemButton selected={selected}>
<ListItemIcon>{props.icon}</ListItemIcon>
<ListItemText primary={props.text} />

View File

@ -107,8 +107,8 @@ export interface AuthenticationResult {
export interface WebauthnDevice {
id: string;
created_at: Date;
last_used_at?: Date;
created_at: string;
last_used_at?: string;
rpid: string;
description: string;
kid: Uint8Array;

View File

@ -144,7 +144,6 @@ export async function startWebauthnRegistration(options: PublicKeyCredentialCrea
};
try {
console.log(JSON.stringify(options));
result.response = await startRegistration(options);
} catch (e) {
const exception = e as DOMException;

View File

@ -20,15 +20,18 @@ import LoginLayout from "@layouts/LoginLayout";
import { completeTOTPRegistrationProcess } from "@services/RegisterDevice";
const RegisterOneTimePassword = function () {
const { t: translate } = useTranslation();
const styles = useStyles();
const navigate = useNavigate();
const { createSuccessNotification, createErrorNotification } = useNotifications();
// The secret retrieved from the API is all is ok.
const [secretURL, setSecretURL] = useState("empty");
const [secretBase32, setSecretBase32] = useState(undefined as string | undefined);
const { createSuccessNotification, createErrorNotification } = useNotifications();
const [hasErrored, setHasErrored] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const { t: translate } = useTranslation();
// Get the token from the query param to give it back to the API when requesting
// the secret for OTP.

View File

@ -6,6 +6,7 @@ import BaseLoadingPage from "@views/LoadingPage/BaseLoadingPage";
const LoadingPage = function () {
const { t: translate } = useTranslation();
return <BaseLoadingPage message={translate("Loading")} />;
};

View File

@ -7,8 +7,10 @@ import { useTranslation } from "react-i18next";
import SuccessIcon from "@components/SuccessIcon";
const Authenticated = function () {
const styles = useStyles();
const { t: translate } = useTranslation();
const styles = useStyles();
return (
<div id="authenticated-stage">
<div className={styles.iconContainer}>

View File

@ -14,10 +14,12 @@ export interface Props {
}
const AuthenticatedView = function (props: Props) {
const styles = useStyles();
const navigate = useNavigate();
const { t: translate } = useTranslation();
const navigate = useNavigate();
const styles = useStyles();
const handleLogoutClick = () => {
navigate(SignOutRoute);
};

View File

@ -47,23 +47,26 @@ function scopeNameToAvatar(id: string) {
}
const ConsentView = function (props: Props) {
const styles = useStyles();
const { t: translate } = useTranslation();
const [userInfo, fetchUserInfo, , fetchUserInfoError] = useUserInfoGET();
const { createErrorNotification, resetNotification } = useNotifications();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const redirect = useRedirector();
const consentID = searchParams.get(Identifier);
const { createErrorNotification, resetNotification } = useNotifications();
const [response, setResponse] = useState<ConsentGetResponseBody | undefined>(undefined);
const [error, setError] = useState<any>(undefined);
const [preConfigure, setPreConfigure] = useState(false);
const styles = useStyles();
const handlePreConfigureChanged = () => {
setPreConfigure((preConfigure) => !preConfigure);
};
const [userInfo, fetchUserInfo, , fetchUserInfoError] = useUserInfoGET();
useEffect(() => {
fetchUserInfo();
}, [fetchUserInfo]);
@ -167,7 +170,7 @@ const ConsentView = function (props: Props) {
<div className={styles.scopesListContainer}>
<List className={styles.scopesList}>
{response?.scopes.map((scope: string) => (
<Tooltip title={"Scope " + scope}>
<Tooltip title={translate("Scope", { name: scope })}>
<ListItem id={"scope-" + scope} dense>
<ListItemIcon>{scopeNameToAvatar(scope)}</ListItemIcon>
<ListItemText primary={translateScopeNameToDescription(scope)} />
@ -180,10 +183,7 @@ const ConsentView = function (props: Props) {
{response?.pre_configuration ? (
<Grid item xs={12}>
<Tooltip
title={
translate("This saves this consent as a pre-configured consent for future use") ||
"This saves this consent as a pre-configured consent for future use"
}
title={translate("This saves this consent as a pre-configured consent for future use")}
>
<FormControlLabel
control={

View File

@ -30,23 +30,27 @@ export interface Props {
}
const FirstFactorForm = function (props: Props) {
const styles = useStyles();
const { t: translate } = useTranslation();
const navigate = useNavigate();
const redirectionURL = useQueryParam(RedirectionURL);
const requestMethod = useQueryParam(RequestMethod);
const [workflow] = useWorkflow();
const { createErrorNotification } = useNotifications();
const loginChannel = useMemo(() => new BroadcastChannel<boolean>("login"), []);
const [rememberMe, setRememberMe] = useState(false);
const [username, setUsername] = useState("");
const [usernameError, setUsernameError] = useState(false);
const [password, setPassword] = useState("");
const [passwordError, setPasswordError] = useState(false);
const { createErrorNotification } = useNotifications();
// TODO (PR: #806, Issue: #511) potentially refactor
const usernameRef = useRef() as MutableRefObject<HTMLInputElement>;
const passwordRef = useRef() as MutableRefObject<HTMLInputElement>;
const { t: translate } = useTranslation();
const styles = useStyles();
useEffect(() => {
const timeout = setTimeout(() => usernameRef.current.focus(), 10);
@ -122,7 +126,7 @@ const FirstFactorForm = function (props: Props) {
onFocus={() => setUsernameError(false)}
autoCapitalize="none"
autoComplete="username"
onKeyPress={(ev) => {
onKeyDown={(ev) => {
if (ev.key === "Enter") {
if (!username.length) {
setUsernameError(true);
@ -152,7 +156,7 @@ const FirstFactorForm = function (props: Props) {
onFocus={() => setPasswordError(false)}
type="password"
autoComplete="current-password"
onKeyPress={(ev) => {
onKeyDown={(ev) => {
if (ev.key === "Enter") {
if (!username.length) {
usernameRef.current.focus();
@ -174,7 +178,7 @@ const FirstFactorForm = function (props: Props) {
disabled={disabled}
checked={rememberMe}
onChange={handleRememberMeChange}
onKeyPress={(ev) => {
onKeyDown={(ev) => {
if (ev.key === "Enter") {
if (!username.length) {
usernameRef.current.focus();

View File

@ -1,6 +1,6 @@
import React, { ReactNode, useState } from "react";
import { Button, Container, Grid, Theme, Typography } from "@mui/material";
import { Box, Button, Container, Grid, Theme, Typography } from "@mui/material";
import makeStyles from "@mui/styles/makeStyles";
import PushNotificationIcon from "@components/PushNotificationIcon";
@ -127,12 +127,12 @@ function DeviceItem(props: DeviceItemProps) {
variant="contained"
onClick={props.onSelect}
>
<div className={style.icon}>
<Box className={style.icon}>
<PushNotificationIcon width={32} height={32} />
</div>
<div>
</Box>
<Box>
<Typography>{props.device.name}</Typography>
</div>
</Box>
</Button>
</Grid>
);
@ -172,12 +172,12 @@ function MethodItem(props: MethodItemProps) {
variant="contained"
onClick={props.onSelect}
>
<div className={style.icon}>
<Box className={style.icon}>
<PushNotificationIcon width={32} height={32} />
</div>
<div>
</Box>
<Box>
<Typography>{props.method}</Typography>
</div>
</Box>
</Button>
</Grid>
);

View File

@ -1,6 +1,6 @@
import React, { ReactNode } from "react";
import { Theme } from "@mui/material";
import { Box, Theme } from "@mui/material";
import makeStyles from "@mui/styles/makeStyles";
import classnames from "classnames";
@ -30,12 +30,12 @@ const IconWithContext = function (props: IconWithContextProps) {
}))();
return (
<div className={classnames(props.className, styles.root)}>
<div className={styles.iconContainer}>
<div className={styles.icon}>{props.icon}</div>
</div>
<div className={styles.context}>{props.children}</div>
</div>
<Box className={classnames(props.className, styles.root)}>
<Box className={styles.iconContainer}>
<Box className={styles.icon}>{props.icon}</Box>
</Box>
<Box className={styles.context}>{props.children}</Box>
</Box>
);
};

View File

@ -28,14 +28,15 @@ export interface Props {
}
const DefaultMethodContainer = function (props: Props) {
const styles = useStyles();
const { t: translate } = useTranslation();
const styles = useStyles();
const registerMessage = props.registered
? props.title === "Push Notification"
? ""
: translate("Manage devices")
: translate("Register device");
const selectMessage = translate("Select a Device");
let container: ReactNode;
let stateClass: string = "";
@ -62,7 +63,7 @@ const DefaultMethodContainer = function (props: Props) {
</div>
{props.onSelectClick && props.registered ? (
<Link component="button" id="selection-link" onClick={props.onSelectClick} underline="hover">
{selectMessage}
{translate("Select a Device")}
</Link>
) : null}
{(props.onRegisterClick && props.title !== "Push Notification") ||

View File

@ -42,7 +42,7 @@ const MethodSelectionDialog = function (props: Props) {
{props.methods.has(SecondFactorMethod.Webauthn) && props.webauthnSupported ? (
<MethodItem
id="webauthn-option"
method={translate("Security Key - WebAuthN")}
method={translate("Webauthn - Security Key")}
icon={<FingerTouchIcon size={32} />}
onClick={() => props.onClick(SecondFactorMethod.Webauthn)}
/>
@ -59,7 +59,7 @@ const MethodSelectionDialog = function (props: Props) {
</DialogContent>
<DialogActions>
<Button color="primary" onClick={props.onClose}>
Close
{translate("Close")}s
</Button>
</DialogActions>
</Dialog>

View File

@ -53,7 +53,7 @@ const ResetPasswordStep1 = function () {
error={error}
value={username}
onChange={(e) => setUsername(e.target.value)}
onKeyPress={(ev) => {
onKeyDown={(ev) => {
if (ev.key === "Enter") {
doInitiateResetPasswordProcess();
ev.preventDefault();

View File

@ -153,7 +153,7 @@ const ResetPasswordStep2 = function () {
value={password2}
onChange={(e) => setPassword2(e.target.value)}
error={errorPassword2}
onKeyPress={(ev) => {
onKeyDown={(ev) => {
if (ev.key === "Enter") {
doResetPassword();
ev.preventDefault();

View File

@ -1,39 +1,42 @@
import React from "react";
import { Button, Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle } from "@mui/material";
import { useTranslation } from "react-i18next";
import { WebauthnDevice } from "@models/Webauthn";
interface Props {
open: boolean;
device: WebauthnDevice | undefined;
device: WebauthnDevice;
handleClose: (ok: boolean) => void;
}
export default function WebauthnDeviceDeleteDialog(props: Props) {
const { t: translate } = useTranslation("settings");
const handleCancel = () => {
props.handleClose(false);
};
return (
<Dialog open={props.open} onClose={handleCancel}>
<DialogTitle>{`Remove ${props.device ? props.device.description : "(unknown)"}`}</DialogTitle>
<DialogTitle>{translate("Remove Webauthn Credential")}</DialogTitle>
<DialogContent>
<DialogContentText>
{`Are you sure you want to remove the device "${
props.device ? props.device.description : "(unknown)"
}" from your account?`}
{translate("Are you sure you want to remove the Webauthn credential from from your account", {
description: props.device.description,
})}
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={handleCancel}>Cancel</Button>
<Button onClick={handleCancel}>{translate("Cancel")}</Button>
<Button
onClick={() => {
props.handleClose(true);
}}
autoFocus
>
Remove
{translate("Remove")}
</Button>
</DialogActions>
</Dialog>

View File

@ -1,5 +1,6 @@
import React, { useState } from "react";
import { Check, ContentCopy } from "@mui/icons-material";
import {
Box,
Button,
@ -13,11 +14,12 @@ import {
} from "@mui/material";
import { useTranslation } from "react-i18next";
import LoadingButton from "@components/LoadingButton";
import { WebauthnDevice } from "@models/Webauthn";
interface Props {
open: boolean;
device: WebauthnDevice | undefined;
device: WebauthnDevice;
handleClose: () => void;
}
@ -26,43 +28,43 @@ export default function WebauthnDetailsDeleteDialog(props: Props) {
return (
<Dialog open={props.open} onClose={props.handleClose}>
<DialogTitle>Security key details</DialogTitle>
<DialogTitle>{translate("Webauthn Credential Details")}</DialogTitle>
<DialogContent>
<DialogContentText sx={{ mb: 3 }}>{`Extended information for security key ${
props.device ? props.device.description : "(unknown)"
}`}</DialogContentText>
{props.device && (
<Stack spacing={0} sx={{ minWidth: 400 }}>
<PropertyText
name={translate("Credential Identifier")}
value={props.device.kid.toString()}
clipboard={true}
/>
<PropertyText
name={translate("Public Key")}
value={props.device.public_key.toString()}
clipboard={true}
/>
<PropertyText name={translate("Relying Party ID")} value={props.device.rpid} />
<PropertyText
name={translate("Authenticator Attestation GUID")}
value={props.device.aaguid === undefined ? "N/A" : props.device.aaguid}
/>
<PropertyText name={translate("Attestation Type")} value={props.device.attestation_type} />
<PropertyText
name={translate("Transports")}
value={props.device.transports.length === 0 ? "N/A" : props.device.transports.join(", ")}
/>
<PropertyText
name={translate("Clone Warning")}
value={props.device.clone_warning ? translate("Yes") : translate("No")}
/>
<PropertyText name={translate("Usage Count")} value={`${props.device.sign_count}`} />
</Stack>
)}
<DialogContentText sx={{ mb: 3 }}>
{translate("Extended Webauthn credential information for security key", {
description: props.device.description,
})}
</DialogContentText>
<Stack spacing={0} sx={{ minWidth: 400 }}>
<Box paddingBottom={2}>
<Stack direction="row" spacing={1} alignItems="center">
<PropertyCopyButton name={translate("Identifier")} value={props.device.kid.toString()} />
<PropertyCopyButton
name={translate("Public Key")}
value={props.device.public_key.toString()}
/>
</Stack>
</Box>
<PropertyText name={translate("Description")} value={props.device.description} />
<PropertyText name={translate("Relying Party ID")} value={props.device.rpid} />
<PropertyText
name={translate("Authenticator Attestation GUID")}
value={props.device.aaguid === undefined ? "N/A" : props.device.aaguid}
/>
<PropertyText name={translate("Attestation Type")} value={props.device.attestation_type} />
<PropertyText
name={translate("Transports")}
value={props.device.transports.length === 0 ? "N/A" : props.device.transports.join(", ")}
/>
<PropertyText
name={translate("Clone Warning")}
value={props.device.clone_warning ? translate("Yes") : translate("No")}
/>
<PropertyText name={translate("Usage Count")} value={`${props.device.sign_count}`} />
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={props.handleClose}>Close</Button>
<Button onClick={props.handleClose}>{translate("Close")}</Button>
</DialogActions>
</Dialog>
);
@ -71,28 +73,55 @@ export default function WebauthnDetailsDeleteDialog(props: Props) {
interface PropertyTextProps {
name: string;
value: string;
clipboard?: boolean;
}
function PropertyText(props: PropertyTextProps) {
function PropertyCopyButton(props: PropertyTextProps) {
const { t: translate } = useTranslation("settings");
const [copied, setCopied] = useState(false);
const [copying, setCopying] = useState(false);
const handleCopyToClipboard = () => {
navigator.clipboard.writeText(props.value);
setCopied(true);
setTimeout(() => {
setCopied(false);
}, 3000);
if (copied) {
return;
}
(async () => {
setCopying(true);
await navigator.clipboard.writeText(props.value);
setTimeout(() => {
setCopying(false);
setCopied(true);
}, 500);
setTimeout(() => {
setCopied(false);
}, 2000);
})();
};
return (
<Box onClick={props.clipboard ? handleCopyToClipboard : undefined}>
<LoadingButton
loading={copying}
variant="outlined"
color={copied ? "success" : "primary"}
onClick={handleCopyToClipboard}
startIcon={copied ? <Check /> : <ContentCopy />}
>
{copied ? translate("Copied") : props.name}
</LoadingButton>
);
}
function PropertyText(props: PropertyTextProps) {
return (
<Box>
<Typography display="inline" sx={{ fontWeight: "bold" }}>
{`${props.name}: `}
</Typography>
<Typography display="inline">
{props.clipboard ? (copied ? "(copied to clipboard)" : "(click to copy)") : props.value}
</Typography>
<Typography display="inline">{props.value}</Typography>
</Box>
);
}

View File

@ -1,19 +1,19 @@
import React, { MutableRefObject, useRef, useState } from "react";
import { Button, Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle } from "@mui/material";
import { Button, Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle, TextField } from "@mui/material";
import { useTranslation } from "react-i18next";
import FixedTextField from "@components/FixedTextField";
import { WebauthnDevice } from "@models/Webauthn";
interface Props {
open: boolean;
device: WebauthnDevice | undefined;
device: WebauthnDevice;
handleClose: (ok: boolean, name: string) => void;
}
export default function WebauthnDeviceEditDialog(props: Props) {
const { t: translate } = useTranslation();
const { t: translate } = useTranslation("settings");
const [deviceName, setName] = useState("");
const nameRef = useRef() as MutableRefObject<HTMLInputElement>;
const [nameError, setNameError] = useState(false);
@ -34,11 +34,10 @@ export default function WebauthnDeviceEditDialog(props: Props) {
return (
<Dialog open={props.open} onClose={handleCancel}>
<DialogTitle>{`Edit ${props.device ? props.device.description : "(unknown)"}`}</DialogTitle>
<DialogTitle>{translate("Edit Webauthn Credential")}</DialogTitle>
<DialogContent>
<DialogContentText>Enter a new name for this device:</DialogContentText>
<FixedTextField
// TODO (PR: #806, Issue: #511) potentially refactor
<DialogContentText>{translate("Enter a new name for this Webauthn credential")}</DialogContentText>
<TextField
autoFocus
inputRef={nameRef}
id="name-textfield"
@ -55,7 +54,7 @@ export default function WebauthnDeviceEditDialog(props: Props) {
}}
autoCapitalize="none"
autoComplete="webauthn-name"
onKeyPress={(ev) => {
onKeyDown={(ev) => {
if (ev.key === "Enter") {
handleConfirm();
ev.preventDefault();
@ -64,8 +63,8 @@ export default function WebauthnDeviceEditDialog(props: Props) {
/>
</DialogContent>
<DialogActions>
<Button onClick={handleCancel}>Cancel</Button>
<Button onClick={handleConfirm}>Update</Button>
<Button onClick={handleCancel}>{translate("Cancel")}</Button>
<Button onClick={handleConfirm}>{translate("Update")}</Button>
</DialogActions>
</Dialog>
);

View File

@ -1,10 +1,10 @@
import React, { Fragment, useState } from "react";
import { Fingerprint } from "@mui/icons-material";
import DeleteIcon from "@mui/icons-material/Delete";
import EditIcon from "@mui/icons-material/Edit";
import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined";
import KeyRoundedIcon from "@mui/icons-material/KeyRounded";
import { Box, Button, Stack, Typography } from "@mui/material";
import { Box, Button, Paper, Stack, Tooltip, Typography } from "@mui/material";
import { useTranslation } from "react-i18next";
import LoadingButton from "@components/LoadingButton";
@ -18,8 +18,7 @@ import WebauthnDeviceEditDialog from "@views/Settings/TwoFactorAuthentication/We
interface Props {
index: number;
device: WebauthnDevice;
handleDeviceEdit(index: number, device: WebauthnDevice): void;
handleDeviceDelete(device: WebauthnDevice): void;
handleEdit: () => void;
}
export default function WebauthnDeviceItem(props: Props) {
@ -49,19 +48,21 @@ export default function WebauthnDeviceItem(props: Props) {
if (response.data.status === "KO") {
if (response.data.elevation) {
createErrorNotification(translate("You must be elevated to update the device"));
createErrorNotification(translate("You must be elevated to update Webauthn credentials"));
} else if (response.data.authentication) {
createErrorNotification(translate("You must have a higher authentication level to update the device"));
createErrorNotification(
translate("You must have a higher authentication level to update Webauthn credentials"),
);
} else {
createErrorNotification(translate("There was a problem updating the device"));
createErrorNotification(translate("There was a problem updating the Webauthn credential"));
}
return;
}
createSuccessNotification(translate("Successfully updated the device"));
createSuccessNotification(translate("Successfully updated the Webauthn credential"));
props.handleDeviceEdit(props.index, { ...props.device, description: name });
props.handleEdit();
};
const handleDelete = async (ok: boolean) => {
@ -79,78 +80,119 @@ export default function WebauthnDeviceItem(props: Props) {
if (response.data.status === "KO") {
if (response.data.elevation) {
createErrorNotification(translate("You must be elevated to delete the device"));
createErrorNotification(translate("You must be elevated to delete Webauthn credentials"));
} else if (response.data.authentication) {
createErrorNotification(translate("You must have a higher authentication level to delete the device"));
createErrorNotification(
translate("You must have a higher authentication level to delete Webauthn credentials"),
);
} else {
createErrorNotification(translate("There was a problem deleting the device"));
createErrorNotification(translate("There was a problem deleting the Webauthn credential"));
}
return;
}
createSuccessNotification(translate("Successfully deleted the device"));
createSuccessNotification(translate("Successfully deleted the Webauthn credential"));
props.handleDeviceDelete(props.device);
props.handleEdit();
};
return (
<Fragment>
<WebauthnDeviceDetailsDialog
device={props.device}
open={showDialogDetails}
handleClose={() => {
setShowDialogDetails(false);
}}
/>
<WebauthnDeviceEditDialog device={props.device} open={showDialogEdit} handleClose={handleEdit} />
<WebauthnDeviceDeleteDialog device={props.device} open={showDialogDelete} handleClose={handleDelete} />
<Stack direction="row" spacing={1} alignItems="center">
<KeyRoundedIcon fontSize="large" />
<Stack spacing={0} sx={{ minWidth: 400 }}>
<Box>
<Typography display="inline" sx={{ fontWeight: "bold" }}>
{props.device.description}
</Typography>
<Typography
display="inline"
variant="body2"
>{` (${props.device.attestation_type.toUpperCase()})`}</Typography>
</Box>
<Typography>Added {props.device.created_at.toString()}</Typography>
<Typography>
{props.device.last_used_at === undefined
? translate("Never used")
: "Last used " + props.device.last_used_at.toString()}
</Typography>
</Stack>
<Button
variant="outlined"
color="primary"
startIcon={<InfoOutlinedIcon />}
onClick={() => setShowDialogDetails(true)}
>
{translate("Info")}
</Button>
<LoadingButton
loading={loadingEdit}
variant="outlined"
color="primary"
startIcon={<EditIcon />}
onClick={() => setShowDialogEdit(true)}
>
{translate("Edit")}
</LoadingButton>
<LoadingButton
loading={loadingDelete}
variant="outlined"
color="secondary"
startIcon={<DeleteIcon />}
onClick={() => setShowDialogDelete(true)}
>
{translate("Remove")}
</LoadingButton>
</Stack>
<Paper variant="outlined">
<Box sx={{ p: 3 }}>
<WebauthnDeviceDetailsDialog
device={props.device}
open={showDialogDetails}
handleClose={() => {
setShowDialogDetails(false);
}}
/>
<WebauthnDeviceEditDialog device={props.device} open={showDialogEdit} handleClose={handleEdit} />
<WebauthnDeviceDeleteDialog
device={props.device}
open={showDialogDelete}
handleClose={handleDelete}
/>
<Stack direction="row" spacing={1} alignItems="center">
<Fingerprint fontSize="large" color={"warning"} />
<Stack spacing={0} sx={{ minWidth: 400 }}>
<Box>
<Typography display="inline" sx={{ fontWeight: "bold" }}>
{props.device.description}
</Typography>
<Typography
display="inline"
variant="body2"
>{` (${props.device.attestation_type.toUpperCase()})`}</Typography>
</Box>
<Typography variant={"caption"}>
{translate("Added", {
when: new Date(props.device.created_at),
formatParams: {
when: {
hour: "numeric",
minute: "numeric",
year: "numeric",
month: "long",
day: "numeric",
},
},
})}
</Typography>
<Typography variant={"caption"}>
{props.device.last_used_at === undefined
? translate("Never used")
: translate("Last Used", {
when: new Date(props.device.last_used_at),
formatParams: {
when: {
hour: "numeric",
minute: "numeric",
year: "numeric",
month: "long",
day: "numeric",
},
},
})}
</Typography>
</Stack>
<Tooltip title={translate("Display extended information for this Webauthn credential")}>
<Button
variant="outlined"
color="primary"
startIcon={<InfoOutlinedIcon />}
onClick={() => setShowDialogDetails(true)}
>
{translate("Info")}
</Button>
</Tooltip>
<Tooltip title={translate("Edit information for this Webauthn credential")}>
<LoadingButton
loading={loadingEdit}
variant="outlined"
color="primary"
startIcon={<EditIcon />}
onClick={() => setShowDialogEdit(true)}
>
{translate("Edit")}
</LoadingButton>
</Tooltip>
<Tooltip title={translate("Remove this Webauthn credential")}>
<LoadingButton
loading={loadingDelete}
variant="outlined"
color="secondary"
startIcon={<DeleteIcon />}
onClick={() => setShowDialogDelete(true)}
>
{translate("Remove")}
</LoadingButton>
</Tooltip>
</Stack>
</Box>
</Paper>
</Fragment>
);
}

View File

@ -1,18 +1,28 @@
import React, { Fragment, MutableRefObject, useCallback, useEffect, useRef, useState } from "react";
import { Box, Button, Grid, Stack, Step, StepLabel, Stepper, Theme, Typography } from "@mui/material";
import {
Box,
Button,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
Grid,
Stack,
Step,
StepLabel,
Stepper,
TextField,
Theme,
Typography,
} from "@mui/material";
import makeStyles from "@mui/styles/makeStyles";
import { PublicKeyCredentialCreationOptionsJSON } from "@simplewebauthn/typescript-types";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import FixedTextField from "@components/FixedTextField";
import InformationIcon from "@components/InformationIcon";
import SuccessIcon from "@components/SuccessIcon";
import WebauthnTryIcon from "@components/WebauthnTryIcon";
import { SettingsRoute, SettingsTwoFactorAuthenticationSubRoute } from "@constants/Routes";
import WebauthnRegisterIcon from "@components/WebauthnRegisterIcon";
import { useNotifications } from "@hooks/NotificationsContext";
import LoginLayout from "@layouts/LoginLayout";
import {
AttestationResult,
AttestationResultFailureString,
@ -24,25 +34,40 @@ import { finishRegistration, getAttestationCreationOptions, startWebauthnRegistr
const steps = ["Confirm device", "Choose name"];
interface Props {
est: AuthenticatorSelectionCriteria;
open: boolean;
onClose: () => void;
setCancelled: () => void;
}
const RegisterWebauthn = function (props: Props) {
const [state, setState] = useState(WebauthnTouchState.WaitTouch);
const WebauthnDeviceRegisterDialog = function (props: Props) {
const { t: translate } = useTranslation("settings");
const styles = useStyles();
const navigate = useNavigate();
const { t: translate } = useTranslation();
const { createErrorNotification } = useNotifications();
const [activeStep, setActiveStep] = React.useState(0);
const [result, setResult] = React.useState(null as null | RegistrationResult);
const [options, setOptions] = useState(null as null | PublicKeyCredentialCreationOptionsJSON);
const [state, setState] = useState(WebauthnTouchState.WaitTouch);
const [activeStep, setActiveStep] = useState(0);
const [result, setResult] = useState<RegistrationResult | null>(null);
const [options, setOptions] = useState<PublicKeyCredentialCreationOptionsJSON | null>(null);
const [timeout, setTimeout] = useState<number | null>(null);
const [deviceName, setName] = useState("");
const nameRef = useRef() as MutableRefObject<HTMLInputElement>;
const [nameError, setNameError] = useState(false);
const handleBackClick = () => {
navigate(`${SettingsRoute}${SettingsTwoFactorAuthenticationSubRoute}`);
const resetStates = () => {
setState(WebauthnTouchState.WaitTouch);
setActiveStep(0);
setResult(null);
setOptions(null);
setTimeout(null);
setName("");
};
const handleClose = () => {
resetStates();
props.setCancelled();
};
const finishAttestation = async () => {
@ -58,8 +83,7 @@ const RegisterWebauthn = function (props: Props) {
const res = await finishRegistration(result.response, deviceName);
switch (res.status) {
case AttestationResult.Success:
setActiveStep(2);
navigate(`${SettingsRoute}${SettingsTwoFactorAuthenticationSubRoute}`);
handleClose();
break;
case AttestationResult.Failure:
createErrorNotification(res.message);
@ -71,7 +95,7 @@ const RegisterWebauthn = function (props: Props) {
return;
}
console.log("start registration");
setTimeout(options.timeout ? options.timeout : null);
try {
setState(WebauthnTouchState.WaitTouch);
@ -79,7 +103,7 @@ const RegisterWebauthn = function (props: Props) {
const res = await startWebauthnRegistration(options);
console.log("got response", res.result);
setTimeout(null);
if (res.result === AttestationResult.Success) {
if (res.response == null) {
@ -103,13 +127,29 @@ const RegisterWebauthn = function (props: Props) {
}, [options, createErrorNotification]);
useEffect(() => {
if (options !== null) {
startRegistration();
if (state !== WebauthnTouchState.Failure || activeStep !== 0 || !props.open) {
return;
}
}, [options, startRegistration]);
handleClose();
}, [props, state, activeStep, handleClose]);
useEffect(() => {
(async () => {
if (options === null || !props.open || activeStep !== 0) {
return;
}
await startRegistration();
})();
}, [options, props.open, activeStep, startRegistration]);
useEffect(() => {
(async () => {
if (!props.open || activeStep !== 0) {
return;
}
const res = await getAttestationCreationOptions();
if (res.status !== 200 || !res.options) {
createErrorNotification(
@ -119,39 +159,31 @@ const RegisterWebauthn = function (props: Props) {
}
setOptions(res.options);
})();
}, [setOptions, createErrorNotification]);
}, [setOptions, createErrorNotification, props.open, activeStep]);
function renderStep(step: number) {
switch (step) {
case 0:
return (
<Fragment>
<div className={styles.icon}>
<WebauthnTryIcon onRetryClick={startRegistration} webauthnTouchState={state} />
</div>
<Typography className={styles.instruction}>Touch the token on your security key</Typography>
<Grid container spacing={1}>
<Grid item xs={12}>
<Stack direction="row" spacing={1} justifyContent="center">
<Button color="primary" onClick={handleBackClick}>
Cancel
</Button>
</Stack>
</Grid>
</Grid>
<Box className={styles.icon}>
{timeout !== null ? <WebauthnRegisterIcon timeout={timeout} /> : null}
</Box>
<Typography className={styles.instruction}>
{translate("Touch the token on your security key")}
</Typography>
</Fragment>
);
case 1:
return (
<div id="webauthn-registration-name">
<div className={styles.icon}>
<Box id="webauthn-registration-name">
<Box className={styles.icon}>
<InformationIcon />
</div>
<Typography className={styles.instruction}>Enter a name for this key</Typography>
</Box>
<Typography className={styles.instruction}>{translate("Enter a name for this key")}</Typography>
<Grid container spacing={1}>
<Grid item xs={12}>
<FixedTextField
// TODO (PR: #806, Issue: #511) potentially refactor
<TextField
inputRef={nameRef}
id="name-textfield"
label={translate("Name")}
@ -159,18 +191,19 @@ const RegisterWebauthn = function (props: Props) {
required
value={deviceName}
error={nameError}
fullWidth
disabled={false}
onChange={(v) => setName(v.target.value.substring(0, 30))}
onFocus={() => setNameError(false)}
autoCapitalize="none"
autoComplete="webauthn-name"
onKeyPress={(ev) => {
onKeyDown={(ev) => {
if (ev.key === "Enter") {
if (!deviceName.length) {
setNameError(true);
} else {
finishAttestation();
(async () => {
await finishAttestation();
})();
}
ev.preventDefault();
}
@ -178,35 +211,32 @@ const RegisterWebauthn = function (props: Props) {
/>
</Grid>
<Grid item xs={12}>
<Stack direction="row" spacing={1} justifyContent="center">
<Button color="primary" variant="outlined" onClick={startRegistration}>
Back
</Button>
<Stack direction="row" spacing={1} justifyContent="center" paddingTop={1}>
<Button color="primary" variant="contained" onClick={finishAttestation}>
Finish
{translate("Finish")}
</Button>
</Stack>
</Grid>
</Grid>
</div>
);
case 2:
return (
<div id="webauthn-registration-success">
<div className={styles.iconContainer}>
<SuccessIcon />
</div>
<Typography>{translate("Registration success")}</Typography>
</div>
</Box>
);
}
}
const handleOnClose = () => {
if (activeStep === 0 || !props.open) {
return;
}
handleClose();
};
return (
<LoginLayout title="Register Security Key">
<Grid container>
<Grid item xs={12} className={styles.methodContainer}>
<Box sx={{ width: "100%" }}>
<Dialog open={props.open} onClose={handleOnClose} maxWidth={"xs"} fullWidth={true}>
<DialogTitle>{translate("Register Webauthn Credential (Security Key)")}</DialogTitle>
<DialogContent>
<Grid container spacing={0} alignItems={"center"} justifyContent={"center"} textAlign={"center"}>
<Grid item xs={12}>
<Stepper activeStep={activeStep}>
{steps.map((label, index) => {
const stepProps: { completed?: boolean } = {};
@ -215,38 +245,34 @@ const RegisterWebauthn = function (props: Props) {
} = {};
return (
<Step key={label} {...stepProps}>
<StepLabel {...labelProps}>{label}</StepLabel>
<StepLabel {...labelProps}>{translate(label)}</StepLabel>
</Step>
);
})}
</Stepper>
</Grid>
<Grid item xs={12}>
{renderStep(activeStep)}
</Box>
</Grid>
</Grid>
</Grid>
</LoginLayout>
</DialogContent>
<DialogActions>
<Button onClick={handleClose} disabled={activeStep === 0 && state !== WebauthnTouchState.Failure}>
{translate("Cancel")}
</Button>
</DialogActions>
</Dialog>
);
};
export default RegisterWebauthn;
export default WebauthnDeviceRegisterDialog;
const useStyles = makeStyles((theme: Theme) => ({
icon: {
paddingTop: theme.spacing(4),
paddingBottom: theme.spacing(4),
},
iconContainer: {
marginBottom: theme.spacing(2),
flex: "0 0 100%",
},
instruction: {
paddingBottom: theme.spacing(4),
},
methodContainer: {
border: "1px solid #d6d6d6",
borderRadius: "10px",
padding: theme.spacing(4),
marginTop: theme.spacing(2),
marginBottom: theme.spacing(2),
},
}));

View File

@ -1,11 +1,11 @@
import React, { Fragment, Suspense } from "react";
import React, { Fragment, Suspense, useState } from "react";
import { Box, Button, Paper, Stack, Typography } from "@mui/material";
import { useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { RegisterWebauthnRoute } from "@constants/Routes";
import { AutheliaState } from "@services/State";
import LoadingPage from "@views/LoadingPage/LoadingPage";
import WebauthnDeviceRegisterDialog from "@views/Settings/TwoFactorAuthentication/WebauthnDeviceRegisterDialog";
import WebauthnDevicesStack from "@views/Settings/TwoFactorAuthentication/WebauthnDevicesStack";
interface Props {
@ -13,31 +13,49 @@ interface Props {
}
export default function WebauthnDevices(props: Props) {
const navigate = useNavigate();
const { t: translate } = useTranslation("settings");
const initiateRegistration = async (redirectRoute: string) => {
navigate(redirectRoute);
};
const [showWebauthnDeviceRegisterDialog, setShowWebauthnDeviceRegisterDialog] = useState<boolean>(false);
const [refreshState, setRefreshState] = useState<number>(0);
const handleAddKeyButtonClick = () => {
initiateRegistration(RegisterWebauthnRoute);
const handleIncrementRefreshState = () => {
setRefreshState((refreshState) => refreshState + 1);
};
return (
<Fragment>
<WebauthnDeviceRegisterDialog
open={showWebauthnDeviceRegisterDialog}
onClose={() => {
handleIncrementRefreshState();
}}
setCancelled={() => {
setShowWebauthnDeviceRegisterDialog(false);
handleIncrementRefreshState();
}}
/>
<Paper variant="outlined">
<Box sx={{ p: 3 }}>
<Stack spacing={2}>
<Box>
<Typography variant="h5">Webauthn Devices</Typography>
<Typography variant="h5">{translate("Webauthn Credentials")}</Typography>
</Box>
<Box>
<Button variant="outlined" color="primary" onClick={handleAddKeyButtonClick}>
{"Add new device"}
<Button
variant="outlined"
color="primary"
onClick={() => {
setShowWebauthnDeviceRegisterDialog(true);
}}
>
{translate("Add Credential")}
</Button>
</Box>
<Suspense fallback={<LoadingPage />}>
<WebauthnDevicesStack />
<WebauthnDevicesStack
refreshState={refreshState}
incrementRefreshState={handleIncrementRefreshState}
/>
</Suspense>
</Stack>
</Box>

View File

@ -1,55 +1,40 @@
import React, { Fragment, useEffect, useState } from "react";
import { Stack, Typography } from "@mui/material";
import { useTranslation } from "react-i18next";
import { WebauthnDevice } from "@models/Webauthn";
import { getWebauthnDevices } from "@services/UserWebauthnDevices";
import WebauthnDeviceItem from "@views/Settings/TwoFactorAuthentication/WebauthnDeviceItem";
interface Props {}
interface Props {
refreshState: number;
incrementRefreshState: () => void;
}
export default function WebauthnDevicesStack(props: Props) {
const { t: translate } = useTranslation("settings");
const [devices, setDevices] = useState<WebauthnDevice[]>([]);
useEffect(() => {
(async function () {
setDevices([]);
const devices = await getWebauthnDevices();
setDevices(devices);
})();
}, []);
const handleEdit = (index: number, device: WebauthnDevice) => {
const nextDevices = devices.map((d, i) => {
if (i === index) {
return device;
} else {
return d;
}
});
setDevices(nextDevices);
};
const handleDelete = (device: WebauthnDevice) => {
setDevices(devices.filter((d) => d.id !== device.id && d.kid !== device.kid));
};
}, [props.refreshState]);
return (
<Fragment>
{devices ? (
{devices.length !== 0 ? (
<Stack spacing={3}>
{devices.map((x, idx) => (
<WebauthnDeviceItem
key={idx}
index={idx}
device={x}
handleDeviceEdit={handleEdit}
handleDeviceDelete={handleDelete}
/>
<WebauthnDeviceItem key={idx} index={idx} device={x} handleEdit={props.incrementRefreshState} />
))}
</Stack>
) : (
<Typography>No Registered Webauthn Devices</Typography>
<Typography>{translate("No Registered Webauthn Credentials")}</Typography>
)}
</Fragment>
);