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 ( 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", "Automatically refresh these permissions without user interaction": "Automatically refresh these permissions without user interaction",
"Cancel": "Cancel", "Cancel": "Cancel",
"Client ID": "Client ID: {{client_id}}", "Client ID": "Client ID: {{client_id}}",
"Close": "Close",
"Consent Request": "Consent Request", "Consent Request": "Consent Request",
"Contact your administrator to register a device": "Contact your administrator to register a device.", "Contact your administrator to register a device": "Contact your administrator to register a device.",
"Could not obtain user settings": "Could not obtain user settings", "Could not obtain user settings": "Could not obtain user settings",
@ -50,8 +51,8 @@
"Reset password?": "Reset password?", "Reset password?": "Reset password?",
"Reset": "Reset", "Reset": "Reset",
"Scan QR Code": "Scan QR Code", "Scan QR Code": "Scan QR Code",
"Scope": "Scope {{name}}",
"Secret": "Secret", "Secret": "Secret",
"Security Key - WebAuthN": "Security Key - WebAuthN",
"Select a Device": "Select a Device", "Select a Device": "Select a Device",
"Sign in": "Sign in", "Sign in": "Sign in",
"Sign out": "Sign out", "Sign out": "Sign out",
@ -67,6 +68,7 @@
"Time-based One-Time Password": "Time-based One-Time Password", "Time-based One-Time Password": "Time-based One-Time Password",
"Use OpenID to verify your identity": "Use OpenID to verify your identity", "Use OpenID to verify your identity": "Use OpenID to verify your identity",
"Username": "Username", "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 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 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", "You're being signed out and redirected": "You're being signed out and redirected",

View File

@ -1,7 +1,9 @@
{ {
"Actions": "Actions", "Actions": "Actions",
"Add": "Add", "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", "Attestation Type": "Attestation Type",
"Authenticator Attestation GUID": "Authenticator Attestation GUID", "Authenticator Attestation GUID": "Authenticator Attestation GUID",
"Cancel": "Cancel", "Cancel": "Cancel",
@ -9,21 +11,39 @@
"Created": "Created", "Created": "Created",
"Delete": "Delete", "Delete": "Delete",
"Details": "Details", "Details": "Details",
"Display extended information for this Webauthn credential": "Display extended information for this Webauthn credential",
"Edit": "Edit", "Edit": "Edit",
"Edit information for this Webauthn credential": "Edit information for this Webauthn credential",
"Edit Webauthn Credential": "Edit Webauthn Credential",
"Enabled": "Enabled", "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", "Manage your security keys": "Manage your security keys",
"Name": "Name", "Name": "Name",
"No": "No", "No": "No",
"No Registered Webauthn Credentials": "No Registered Webauthn Credentials",
"Overview": "Overview", "Overview": "Overview",
"Provide the details for the new security key": "Provide the details for the new security key", "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", "Relying Party ID": "Relying Party ID",
"Remove": "Remove",
"Remove this Webauthn credential": "Remove this Webauthn credential",
"Remove Webauthn Credential": "Remove Webauthn Credential",
"Settings": "Settings", "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", "Transports": "Transports",
"Two-Factor Authentication": "Two-Factor Authentication", "Two-Factor Authentication": "Two-Factor Authentication",
"Usage Count": "Usage Count", "Usage Count": "Usage Count",
"Webauthn Credential Identifier": "Credential Identifier: {{id}}", "Webauthn Credential Details": "Webauthn Credential Details",
"Webauthn Public Key": "Public Key: {{key}}", "Webauthn Credentials": "Webauthn Credentials",
"Yes": "Yes" "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", "axios": "1.3.2",
"broadcast-channel": "4.20.2", "broadcast-channel": "4.20.2",
"classnames": "2.3.2", "classnames": "2.3.2",
"date-fns": "2.29.3",
"i18next": "22.4.9", "i18next": "22.4.9",
"i18next-browser-languagedetector": "7.0.1", "i18next-browser-languagedetector": "7.0.1",
"i18next-http-backend": "2.1.1", "i18next-http-backend": "2.1.1",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -6,10 +6,10 @@ import { useTranslation } from "react-i18next";
import { getPrivacyPolicyURL } from "@utils/Configuration"; import { getPrivacyPolicyURL } from "@utils/Configuration";
const PrivacyPolicyLink = function (props: LinkProps) { const PrivacyPolicyLink = function (props: LinkProps) {
const hrefPrivacyPolicy = getPrivacyPolicyURL();
const { t: translate } = useTranslation(); const { t: translate } = useTranslation();
const hrefPrivacyPolicy = getPrivacyPolicyURL();
return ( return (
<Fragment> <Fragment>
<Link {...props} href={hrefPrivacyPolicy} target="_blank" rel="noopener" underline="hover"> <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 ResetPasswordStep1Route: string = "/reset-password/step1";
export const ResetPasswordStep2Route: string = "/reset-password/step2"; export const ResetPasswordStep2Route: string = "/reset-password/step2";
export const RegisterWebauthnRoute: string = "/webauthn/register";
export const RegisterOneTimePasswordRoute: string = "/one-time-password/register"; export const RegisterOneTimePasswordRoute: string = "/one-time-password/register";
export const LogoutRoute: string = "/logout"; export const LogoutRoute: string = "/logout";

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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