feat (dashboard): Add mfa (#3342)
### **PR Type** Enhancement ___ ### **Description** - Add multi-factor authentication (MFA) to dashboard - Implement MFA OTP form and QR code generation - Create MFA settings and activation components - Update sign-in process to support MFA ___ ### **Changes walkthrough** 📝 <table><thead><tr><th></th><th align="left">Relevant files</th></tr></thead><tbody><tr><td><strong>Enhancement</strong></td><td><details><summary>19 files</summary><table> <tr> <td><strong>MfaOtpForm.tsx</strong><dd><code>Create reusable MFA OTP form component</code> </dd></td> <td><a href="https://github.com/nhost/nhost/pull/3342/files#diff-88ee3610a0658d5eead85db025a5e91e74a4d2f2a836adf7eb44ff80888a613b">+61/-0</a> </td> </tr> <tr> <td><strong>index.ts</strong><dd><code>Export MfaOtpForm component</code> </dd></td> <td><a href="https://github.com/nhost/nhost/pull/3342/files#diff-9c1deb50c3a92ca5494be705635984a97e1b41b07cd0847168a4eeddf0e375d0">+1/-0</a> </td> </tr> <tr> <td><strong>AccountMfaSettings.tsx</strong><dd><code>Implement MFA settings component</code> </dd></td> <td><a href="https://github.com/nhost/nhost/pull/3342/files#diff-4eb33e0f23780eaf93fd7d86850b263d83b05dc2d7a3f6ed9e30d1ca811f17af">+32/-0</a> </td> </tr> <tr> <td><strong>GenerateMfaQRCodeButton.tsx</strong><dd><code>Create button to generate MFA QR code</code> </dd></td> <td><a href="https://github.com/nhost/nhost/pull/3342/files#diff-7310648a5e879bb76ba6c3136fe555ed3bbdacddc33eef4ce8fc9c21a547ec82">+50/-0</a> </td> </tr> <tr> <td><strong>MfaQRCode.tsx</strong><dd><code>Implement MFA QR code generation component</code> </dd></td> <td><a href="https://github.com/nhost/nhost/pull/3342/files#diff-0c60d61f12b47e421c67c389c66399da76af4b32241610fe94c6635353e57da2">+49/-0</a> </td> </tr> <tr> <td><strong>useActivateMfa.ts</strong><dd><code>Create hook for MFA activation</code> </dd></td> <td><a href="https://github.com/nhost/nhost/pull/3342/files#diff-0ae70fc9df5a3a6828f7a266db8036107ce9ea705cd318d3a1c4b7304d8522ba">+46/-0</a> </td> </tr> <tr> <td><strong>useMfaEnabled.ts</strong><dd><code>Create hook to check MFA status</code> </dd></td> <td><a href="https://github.com/nhost/nhost/pull/3342/files#diff-31d2af339a8dd32beff8cce79962fa0dd23b6c89687b21aa75663ebeccb0b154">+17/-0</a> </td> </tr> <tr> <td><strong>index.ts</strong><dd><code>Export AccountMfaSettings component</code> </dd></td> <td><a href="https://github.com/nhost/nhost/pull/3342/files#diff-8c530fc016dd3569f2b7ec7e9085b99c99922ed077357bec562b8c9acaead24a">+1/-0</a> </td> </tr> <tr> <td><strong>SecurityKeyList.tsx</strong><dd><code>Update import path for useGetSecurityKeys</code> </dd></td> <td><a href="https://github.com/nhost/nhost/pull/3342/files#diff-966a157d381be33bc876e76b28f804e80cae6edb1aa088e78f883063966be3ba">+1/-1</a> </td> </tr> <tr> <td><strong>useOnAddNewSecurityKeyHandler.ts</strong><dd><code>Update import paths for hooks</code> </dd></td> <td><a href="https://github.com/nhost/nhost/pull/3342/files#diff-3514a6d1514269a83f37fc25e9cb24add9d5d74f9cf3341293c0e0f2a4c2e286">+2/-2</a> </td> </tr> <tr> <td><strong>useRemoveSecurityKey.ts</strong><dd><code>Update import paths for hooks</code> </dd></td> <td><a href="https://github.com/nhost/nhost/pull/3342/files#diff-5683e00a14f39018d8fe58a3116c2a8ea6d2f2a83abb2177bbf0ee8ddf0f97b5">+2/-2</a> </td> </tr> <tr> <td><strong>useElevatedPermissions.ts</strong><dd><code>Create hook for elevated permissions</code> </dd></td> <td><a href="https://github.com/nhost/nhost/pull/3342/files#diff-c1e4f573300c771149cc2e59918c9acf2ae5f8a6680800a899707c70800ba144">[link]</a> </td> </tr> <tr> <td><strong>useGetSecurityKeys.ts</strong><dd><code>Create hook to fetch security keys</code> </dd></td> <td><a href="https://github.com/nhost/nhost/pull/3342/files#diff-1f9fed870cab61f15e304342e4913edab0f5537eeb6230070de4b4f7173fa138">[link]</a> </td> </tr> <tr> <td><strong>MfaSignInOtpForm.tsx</strong><dd><code>Create MFA sign-in OTP form component</code> </dd></td> <td><a href="https://github.com/nhost/nhost/pull/3342/files#diff-91eba232beb0543b1e972ed9a21a0be797ed94b720487834bb3316a5dbd732f5">+26/-0</a> </td> </tr> <tr> <td><strong>SignInWithEmailAndPassword.tsx</strong><dd><code>Update sign-in component to support MFA</code> </dd></td> <td><a href="https://github.com/nhost/nhost/pull/3342/files#diff-a2b70644663baf4f6f2cdffd846d4d743a5ca1f2a64c4b278b6f04c6c5c92161">+16/-0</a> </td> </tr> <tr> <td><strong>SignInWithEmailAndPasswordForm.tsx</strong><dd><code>Implement sign-in form with MFA support</code> </dd></td> <td><a href="https://github.com/nhost/nhost/pull/3342/files#diff-a07fd6bd20c97d0c9c875e690cd3a80068fc58f74d3579feb210e189d32f5031">+91/-0</a> </td> </tr> <tr> <td><strong>useOnSignInWithEmailAndPasswordHandler.ts</strong><dd><code>Create hook for sign-in with MFA</code> </dd></td> <td><a href="https://github.com/nhost/nhost/pull/3342/files#diff-1a253bfc02c3267ab1c6b58c07aa06142b7e711d613b672c8420ff2861b12d27">+56/-0</a> </td> </tr> <tr> <td><strong>useSignInWithEmailAndPasswordForm.ts</strong><dd><code>Create hook for sign-in form validation</code> </dd></td> <td><a href="https://github.com/nhost/nhost/pull/3342/files#diff-b908e474c0fb54db9c922d9fef7cf1ef6c4ccb0dd7519da0c45a18e5bb26ed40">+30/-0</a> </td> </tr> <tr> <td><strong>index.ts</strong><dd><code>Export SignInWithEmailAndPassword component</code> </dd></td> <td><a href="https://github.com/nhost/nhost/pull/3342/files#diff-d3fd195b5ca8ece9eac446129e8501793e5bd6e5c167ed36c8c6d0adc1723fda">+1/-0</a> </td> </tr> </table></details></td></tr><tr><td><strong>Additional files</strong></td><td><details><summary>8 files</summary><table> <tr> <td><strong>mighty-onions-crash.md</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3342/files#diff-834d585225de297c20c9e325a231c6d3a72227fc1d8cc84b0c1f8fe0dbb1c523">+7/-0</a> </td> </tr> <tr> <td><strong>getActiveMfaType.gql</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3342/files#diff-ac5aa6c409363b550d15aace147448c5e267a3cf0fb7f86faf5060f8cbe35302">+5/-0</a> </td> </tr> <tr> <td><strong>index.tsx</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3342/files#diff-61a48d15d3a2e29160a6d91cd01501ac94cf9f70995c6a84fbb6d6e2c2d4fca1">+4/-0</a> </td> </tr> <tr> <td><strong>email.tsx</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3342/files#diff-b5d7db4460066bc114cb766771612d6f908bd6e440f40de98e4ac311a26b50cd">+16/-152</a></td> </tr> <tr> <td><strong>graphql.ts</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3342/files#diff-fbd5db84b560b1c91675004448c6c7fa0dcbfb28b9eb05d53b03e6cb7b83ebac">+80/-35</a> </td> </tr> <tr> <td><strong>enable-mfa.ts</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3342/files#diff-8ed0174991b707a5c54f54ec881656403b4409cd0e3d7004045a80dbeb7b4444">+1/-4</a> </td> </tr> <tr> <td><strong>index.ts</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3342/files#diff-1cee8646d2cfba37d6ce6a6e9a8d16f8caba0b99fc3a1ad0cb997ed8c7384d2e">+7/-1</a> </td> </tr> <tr> <td><strong>useSignInEmailPassword.ts</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3342/files#diff-107884d4022cd6c01459f001fa97d2b2ce11566a2c88c8deaec4727c1af44aba">+6/-8</a> </td> </tr> </table></details></td></tr></tr></tbody></table> ___ > <details> <summary> Need help?</summary><li>Type <code>/help how to ...</code> in the comments thread for any questions about PR-Agent usage.</li><li>Check out the <a href="https://qodo-merge-docs.qodo.ai/usage-guide/">documentation</a> for more information.</li></details>
This commit is contained in:
7
.changeset/mighty-onions-crash.md
Normal file
7
.changeset/mighty-onions-crash.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
'@nhost/hasura-auth-js': minor
|
||||
'@nhost/react': minor
|
||||
'@nhost/dashboard': minor
|
||||
---
|
||||
|
||||
feat (dashboard): Add multi-factor authentication
|
||||
61
dashboard/src/components/common/MfaOtpForm/MfaOtpForm.tsx
Normal file
61
dashboard/src/components/common/MfaOtpForm/MfaOtpForm.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import { Button } from '@/components/ui/v3/button';
|
||||
import { Input } from '@/components/ui/v3/input';
|
||||
import { type ChangeEvent, useEffect, useRef, useState } from 'react';
|
||||
|
||||
interface Props {
|
||||
sendMfaOtp: (code: string) => Promise<any>;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
function MfaOtpForm({ sendMfaOtp, loading }: Props) {
|
||||
const [otpValue, setOtpValue] = useState<string>('');
|
||||
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
}
|
||||
}, []);
|
||||
|
||||
async function sendMfa(code: string) {
|
||||
if (code.length === 6 && !isSubmitting) {
|
||||
setIsSubmitting(true);
|
||||
const result = await sendMfaOtp(code);
|
||||
if (!result) {
|
||||
setTimeout(() => {
|
||||
inputRef.current?.focus();
|
||||
}, 10);
|
||||
}
|
||||
}
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
|
||||
async function handleChange(event: ChangeEvent<HTMLInputElement>) {
|
||||
const code = event.target.value.replace(/[^0-9]/g, '').slice(0, 6);
|
||||
setOtpValue(code);
|
||||
|
||||
sendMfa(code);
|
||||
}
|
||||
|
||||
const isInputDisabled = loading || isSubmitting;
|
||||
const isButtonDisabled = isInputDisabled || otpValue.length !== 6;
|
||||
|
||||
return (
|
||||
<div className="relative grid w-full grid-flow-row gap-4 bg-transparent">
|
||||
<Input
|
||||
ref={inputRef}
|
||||
value={otpValue}
|
||||
placeholder="Enter TOTP"
|
||||
className="!bg-transparent"
|
||||
disabled={isInputDisabled}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
<Button disabled={isButtonDisabled}>
|
||||
{loading ? 'Verifying...' : 'Verify'}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default MfaOtpForm;
|
||||
1
dashboard/src/components/common/MfaOtpForm/index.ts
Normal file
1
dashboard/src/components/common/MfaOtpForm/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as MfaOtpForm } from './MfaOtpForm';
|
||||
@@ -6,8 +6,8 @@ import {
|
||||
type ReactElement,
|
||||
} from 'react';
|
||||
|
||||
import { CopyToClipboardButton as CopyToClipboardButtonOriginal } from '@/components/presentational/CopyToClipboardButton';
|
||||
import { Box } from '@/components/ui/v2/Box';
|
||||
import { CopyToClipboardButton as CopyToClipboardButtonOriginal } from './CopyToClipboardButton';
|
||||
import { getNodeText } from './getNodeText';
|
||||
|
||||
export interface CodeBlockPropsBase {
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
import { Button, type ButtonProps } from '@/components/ui/v3/button';
|
||||
import { isNotEmptyValue } from '@/lib/utils';
|
||||
import { copy } from '@/utils/copy';
|
||||
import { clsx } from 'clsx';
|
||||
import { Copy } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import {
|
||||
IconButton,
|
||||
type IconButtonProps,
|
||||
} from '@/components/ui/v2/IconButton';
|
||||
import { CopyIcon } from '@/components/ui/v2/icons/CopyIcon';
|
||||
import { copy } from '@/utils/copy';
|
||||
|
||||
export function CopyToClipboardButton({
|
||||
function CopyToClipboardButton({
|
||||
textToCopy,
|
||||
className,
|
||||
title,
|
||||
@@ -16,7 +13,7 @@ export function CopyToClipboardButton({
|
||||
}: {
|
||||
textToCopy: string;
|
||||
title: string;
|
||||
} & IconButtonProps) {
|
||||
} & ButtonProps) {
|
||||
const [disabled, setDisabled] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -35,12 +32,18 @@ export function CopyToClipboardButton({
|
||||
if (!textToCopy || disabled) {
|
||||
return null;
|
||||
}
|
||||
const hasChildren = isNotEmptyValue(props.children);
|
||||
|
||||
return (
|
||||
<IconButton
|
||||
variant="borderless"
|
||||
color="secondary"
|
||||
className={clsx('group', className)}
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="outline"
|
||||
className={clsx(
|
||||
'group h-fit w-fit border-0 bg-transparent p-[2px] hover:bg-[#d6eefb] dark:hover:bg-[#1e2942]',
|
||||
className,
|
||||
{ 'gap-3': hasChildren },
|
||||
)}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
|
||||
@@ -49,7 +52,9 @@ export function CopyToClipboardButton({
|
||||
aria-label={textToCopy}
|
||||
{...props}
|
||||
>
|
||||
<CopyIcon className="top-5 h-4 w-4" />
|
||||
</IconButton>
|
||||
{props.children}
|
||||
<Copy className="top-5 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
export default CopyToClipboardButton;
|
||||
@@ -0,0 +1 @@
|
||||
export { default as CopyToClipboardButton } from './CopyToClipboardButton';
|
||||
@@ -0,0 +1,44 @@
|
||||
import { Badge } from '@/components/ui/v3/badge';
|
||||
import useMfaEnabled from '@/features/account/settings/components/AccountMfaSettings/hooks/useMfaEnabled';
|
||||
import DisableMfaButton from './DisableMfaButton/DisableMfaButton';
|
||||
import EnableMfaButton from './EnableMfaButton/EnableMfaButton';
|
||||
|
||||
function MFaEnabledBadge() {
|
||||
return (
|
||||
<Badge variant="outline" className="border-green-400 text-green-400">
|
||||
Enabled
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
function MFaDisabledBadge() {
|
||||
return (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text- border-destructive text-destructive"
|
||||
>
|
||||
Disabled
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
function AccountMfaSettings() {
|
||||
const { isMfaEnabled } = useMfaEnabled();
|
||||
return (
|
||||
<div className="rounded-lg border border-[#EAEDF0] bg-white font-['Inter_var'] dark:border-[#2F363D] dark:bg-paper">
|
||||
<div className="flex w-full flex-col items-start gap-6 p-4">
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<h3 className="flex items-center text-[1.125rem] font-semibold leading-[1.75]">
|
||||
<span className="mr-4">Multi-Factor Authentication </span>
|
||||
{isMfaEnabled ? <MFaEnabledBadge /> : <MFaDisabledBadge />}
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex w-full items-center border-t border-[#EAEDF0] px-4 py-2 dark:border-[#2F363D]">
|
||||
{isMfaEnabled ? <DisableMfaButton /> : <EnableMfaButton />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AccountMfaSettings;
|
||||
@@ -0,0 +1,70 @@
|
||||
import { MfaOtpForm } from '@/components/common/MfaOtpForm';
|
||||
import { Button } from '@/components/ui/v3/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/v3/dialog';
|
||||
import useMfaEnabled from '@/features/account/settings/components/AccountMfaSettings/hooks/useMfaEnabled';
|
||||
import { getToastStyleProps } from '@/utils/constants/settings';
|
||||
import { useConfigMfa } from '@nhost/nextjs';
|
||||
import { useState } from 'react';
|
||||
import { toast } from 'react-hot-toast';
|
||||
|
||||
const defaultErrorMessage =
|
||||
'An error occurred while trying to enable multi-factor authentication. Please try again.';
|
||||
|
||||
function DisableMfaButton() {
|
||||
const { disableMfa, isDisabling } = useConfigMfa();
|
||||
const [open, setOpen] = useState(false);
|
||||
const { loading, refetch } = useMfaEnabled();
|
||||
const buttonDisabled = loading || isDisabling;
|
||||
|
||||
async function onSendMfaOtp(code: string) {
|
||||
const result = await disableMfa(code);
|
||||
if (result.error) {
|
||||
toast.error(
|
||||
result.error.message || defaultErrorMessage,
|
||||
getToastStyleProps(),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
toast.success(
|
||||
'Multi-factor authentication has been disabled.',
|
||||
getToastStyleProps(),
|
||||
);
|
||||
await refetch();
|
||||
setOpen(false);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={buttonDisabled}
|
||||
className="p-y[0.375rem] h-9 gap-2 border-destructive px-2 text-destructive hover:bg-destructive"
|
||||
>
|
||||
Disable multi-factor authentication
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="z-[9999] max-w-[28rem] text-foreground">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Disable multi-factor authentication</DialogTitle>
|
||||
</DialogHeader>
|
||||
<DialogDescription className="hidden">
|
||||
Disable multi-factor authentication
|
||||
</DialogDescription>
|
||||
|
||||
<MfaOtpForm loading={isDisabling} sendMfaOtp={onSendMfaOtp} />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default DisableMfaButton;
|
||||
@@ -0,0 +1 @@
|
||||
export { default as DisableMfaButton } from './DisableMfaButton';
|
||||
@@ -0,0 +1,19 @@
|
||||
import { CopyToClipboardButton } from '@/components/presentational/CopyToClipboardButton';
|
||||
|
||||
interface Props {
|
||||
totpSecret: string | null;
|
||||
}
|
||||
|
||||
function CopyMfaTOTPSecret({ totpSecret }: Props) {
|
||||
return (
|
||||
<CopyToClipboardButton
|
||||
className="p-2"
|
||||
textToCopy={totpSecret}
|
||||
title="TOTP secret"
|
||||
>
|
||||
OR Copy TOTP secret
|
||||
</CopyToClipboardButton>
|
||||
);
|
||||
}
|
||||
|
||||
export default CopyMfaTOTPSecret;
|
||||
@@ -0,0 +1,48 @@
|
||||
import { Button } from '@/components/ui/v3/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/v3/dialog';
|
||||
import useMfaEnabled from '@/features/account/settings/components/AccountMfaSettings/hooks/useMfaEnabled';
|
||||
import { useState } from 'react';
|
||||
import MfaQRCodeAndTOTPSecret from './MfaQRCodeAndTOTPSecret';
|
||||
|
||||
function EnableMfaButton() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const { isMfaEnabled, loading, refetch } = useMfaEnabled();
|
||||
const buttonDisabled = loading || isMfaEnabled;
|
||||
|
||||
async function handleOnSuccess() {
|
||||
setOpen(false);
|
||||
await refetch();
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={buttonDisabled}
|
||||
className="p-y[0.375rem] h-9 gap-2 border-green-600 px-2 text-green-600 hover:bg-destructive hover:bg-green-600"
|
||||
>
|
||||
Enable multi-factor authentication
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="z-[9999] max-w-[28rem] text-foreground">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Enable multi-factor authentication</DialogTitle>
|
||||
</DialogHeader>
|
||||
<DialogDescription className="hidden">
|
||||
Enable multi-factor authentication
|
||||
</DialogDescription>
|
||||
<MfaQRCodeAndTOTPSecret onSuccess={handleOnSuccess} />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default EnableMfaButton;
|
||||
@@ -0,0 +1,74 @@
|
||||
/* eslint-disable @next/next/no-img-element */
|
||||
import { MfaOtpForm } from '@/components/common/MfaOtpForm';
|
||||
import { Spinner } from '@/components/ui/v3/spinner';
|
||||
import { getToastStyleProps } from '@/utils/constants/settings';
|
||||
import { useConfigMfa } from '@nhost/nextjs';
|
||||
import { useEffect } from 'react';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import CopyMfaTOTPSecret from './CopyMfaTOTPSecret';
|
||||
|
||||
const defaultErrorMessage =
|
||||
'An error occurred while trying to enable multi-factor authentication. Please try again.';
|
||||
|
||||
interface Props {
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
function MfaQRCodeAndTOTPSecret({ onSuccess }: Props) {
|
||||
const {
|
||||
generateQrCode,
|
||||
qrCodeDataUrl,
|
||||
isGenerated,
|
||||
isGenerating,
|
||||
activateMfa,
|
||||
isActivating,
|
||||
totpSecret,
|
||||
} = useConfigMfa();
|
||||
|
||||
async function onSendMfaOtp(code: string) {
|
||||
const result = await activateMfa(code);
|
||||
if (result.error) {
|
||||
toast.error(
|
||||
result.error.message || defaultErrorMessage,
|
||||
getToastStyleProps(),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
toast.success(
|
||||
'Multi-factor authentication has been enabled.',
|
||||
getToastStyleProps(),
|
||||
);
|
||||
onSuccess();
|
||||
return true;
|
||||
}
|
||||
useEffect(() => {
|
||||
async function generate() {
|
||||
const result = await generateQrCode();
|
||||
if (result.error) {
|
||||
toast.error(result.error.message, getToastStyleProps());
|
||||
}
|
||||
}
|
||||
generate();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col items-center justify-center gap-4">
|
||||
{isGenerating && <Spinner />}
|
||||
{isGenerated && qrCodeDataUrl && (
|
||||
<>
|
||||
<div className="flex flex-col justify-center gap-4">
|
||||
<p className="text-base">
|
||||
Scan the QR Code with your authenticator app
|
||||
</p>
|
||||
<img alt="qrcode" src={qrCodeDataUrl} className="mx-auto w-64" />
|
||||
</div>
|
||||
<CopyMfaTOTPSecret totpSecret={totpSecret} />
|
||||
<MfaOtpForm loading={isActivating} sendMfaOtp={onSendMfaOtp} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default MfaQRCodeAndTOTPSecret;
|
||||
@@ -0,0 +1 @@
|
||||
export { default as EnableMfaButton } from './EnableMfaButton';
|
||||
@@ -0,0 +1,17 @@
|
||||
import { isNotEmptyValue } from '@/lib/utils';
|
||||
import { useGetActiveMfaTypeQuery } from '@/utils/__generated__/graphql';
|
||||
import { useUserId } from '@nhost/nextjs';
|
||||
|
||||
function useMfaEnabled() {
|
||||
const userId = useUserId();
|
||||
const { data, loading, refetch } = useGetActiveMfaTypeQuery({
|
||||
variables: { id: userId },
|
||||
fetchPolicy: 'cache-first',
|
||||
});
|
||||
|
||||
const isMfaEnabled = isNotEmptyValue(data?.user.activeMfaType);
|
||||
|
||||
return { loading, isMfaEnabled, refetch };
|
||||
}
|
||||
|
||||
export default useMfaEnabled;
|
||||
@@ -0,0 +1 @@
|
||||
export { default as AccountMfaSettings } from './components/AccountMfaSettings';
|
||||
@@ -10,9 +10,9 @@ import { CopyIcon } from '@/components/ui/v2/icons/CopyIcon';
|
||||
import { Input } from '@/components/ui/v2/Input';
|
||||
import { Option } from '@/components/ui/v2/Option';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import useActionWithElevatedPermissions from '@/features/account/settings/hooks/useActionWithElevatedPermissions';
|
||||
import type { DialogFormProps } from '@/types/common';
|
||||
import { GetPersonalAccessTokensDocument } from '@/utils/__generated__/graphql';
|
||||
import { getToastStyleProps } from '@/utils/constants/settings';
|
||||
import { copy } from '@/utils/copy';
|
||||
import { getDateComponents } from '@/utils/getDateComponents';
|
||||
import { useApolloClient } from '@apollo/client';
|
||||
@@ -20,7 +20,6 @@ import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import { useNhostClient } from '@nhost/nextjs';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import * as Yup from 'yup';
|
||||
|
||||
export const createPATFormValidationSchema = Yup.object({
|
||||
@@ -84,6 +83,25 @@ export default function CreatePATForm({
|
||||
resolver: yupResolver(createPATFormValidationSchema),
|
||||
});
|
||||
|
||||
const createPAT = useActionWithElevatedPermissions({
|
||||
actionFn: async (
|
||||
expiresAt: Date,
|
||||
metadata?: Record<string, string | number>,
|
||||
) => {
|
||||
const result = await nhostClient.auth.createPAT(expiresAt, metadata);
|
||||
return result;
|
||||
},
|
||||
successMessage: 'The personal access token has been created successfully.',
|
||||
onSuccess: ({ data }) => {
|
||||
setPersonalAccessToken(data?.personalAccessToken);
|
||||
apolloClient.refetchQueries({
|
||||
include: [GetPersonalAccessTokensDocument],
|
||||
});
|
||||
|
||||
form.reset();
|
||||
},
|
||||
});
|
||||
|
||||
const { register, formState } = form;
|
||||
|
||||
const isDirty = Object.keys(formState.dirtyFields).length > 0;
|
||||
@@ -93,44 +111,11 @@ export default function CreatePATForm({
|
||||
}, [isDirty, location, onDirtyStateChange]);
|
||||
|
||||
async function handleSubmit(formValues: CreatePATFormValues) {
|
||||
try {
|
||||
const { error, data } = await nhostClient.auth.createPAT(
|
||||
new Date(formValues.expiresAt),
|
||||
{
|
||||
name: formValues.name,
|
||||
application: 'dashboard',
|
||||
userAgent: window.navigator.userAgent,
|
||||
},
|
||||
);
|
||||
|
||||
const toastStyle = getToastStyleProps();
|
||||
|
||||
if (error) {
|
||||
toast.error(error.message, {
|
||||
style: toastStyle.style,
|
||||
...toastStyle.error,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success(
|
||||
'The personal access token has been created successfully.',
|
||||
{
|
||||
style: toastStyle.style,
|
||||
...toastStyle.success,
|
||||
},
|
||||
);
|
||||
|
||||
setPersonalAccessToken(data?.personalAccessToken);
|
||||
|
||||
apolloClient.refetchQueries({
|
||||
include: [GetPersonalAccessTokensDocument],
|
||||
});
|
||||
|
||||
form.reset();
|
||||
} catch {
|
||||
// Note: This error is handled by the toast.
|
||||
}
|
||||
await createPAT(new Date(formValues.expiresAt), {
|
||||
name: formValues.name,
|
||||
application: 'dashboard',
|
||||
userAgent: window.navigator.userAgent,
|
||||
});
|
||||
}
|
||||
|
||||
if (personalAccessToken) {
|
||||
|
||||
@@ -19,7 +19,7 @@ export type DisplayNameSettingFormValues = Yup.InferType<
|
||||
>;
|
||||
|
||||
export default function DisplayNameSetting() {
|
||||
const { id: userID, displayName } = useUserData();
|
||||
const { id: userID, displayName } = useUserData() || {};
|
||||
|
||||
const [updateUserDisplayName] = useUpdateUserDisplayNameMutation();
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Form } from '@/components/form/Form';
|
||||
import { SettingsContainer } from '@/components/layout/SettingsContainer';
|
||||
import { Input } from '@/components/ui/v2/Input';
|
||||
import { execPromiseWithErrorToast } from '@/features/orgs/utils/execPromiseWithErrorToast';
|
||||
import useActionWithElevatedPermissions from '@/features/account/settings/hooks/useActionWithElevatedPermissions';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import { useNhostClient, useUserData } from '@nhost/nextjs';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
@@ -15,7 +15,7 @@ export type EmailSettingFormValues = Yup.InferType<typeof validationSchema>;
|
||||
|
||||
export default function EmailSetting() {
|
||||
const nhost = useNhostClient();
|
||||
const { email } = useUserData();
|
||||
const { email } = useUserData() || {};
|
||||
|
||||
const form = useForm<EmailSettingFormValues>({
|
||||
reValidateMode: 'onSubmit',
|
||||
@@ -26,25 +26,23 @@ export default function EmailSetting() {
|
||||
const { register, formState } = form;
|
||||
const isDirty = Object.keys(formState.dirtyFields).length > 0;
|
||||
|
||||
const changeEmail = useActionWithElevatedPermissions({
|
||||
actionFn: async (newEmail: string) => {
|
||||
const result = await nhost.auth.changeEmail({
|
||||
newEmail,
|
||||
options: {
|
||||
redirectTo: `${window.location.origin}/account`,
|
||||
},
|
||||
});
|
||||
return result;
|
||||
},
|
||||
successMessage:
|
||||
'Please check your inbox. Follow the link to finalize changing your email.',
|
||||
onSuccess: () => form.reset(),
|
||||
});
|
||||
|
||||
async function handleSubmit(formValues: EmailSettingFormValues) {
|
||||
await execPromiseWithErrorToast(
|
||||
async () => {
|
||||
await nhost.auth.changeEmail({
|
||||
newEmail: formValues.email,
|
||||
options: {
|
||||
redirectTo: `${window.location.origin}/account`,
|
||||
},
|
||||
});
|
||||
form.reset({ email: formValues.email });
|
||||
},
|
||||
{
|
||||
loadingMessage: 'Updating your email...',
|
||||
successMessage:
|
||||
'Please check your inbox. Follow the link to finalize changing your email.',
|
||||
errorMessage:
|
||||
'An error occurred while trying to update your email. Please try again.',
|
||||
},
|
||||
);
|
||||
await changeEmail(formValues.email);
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
import { Form } from '@/components/form/Form';
|
||||
import { SettingsContainer } from '@/components/layout/SettingsContainer';
|
||||
import { Input } from '@/components/ui/v2/Input';
|
||||
import { execPromiseWithErrorToast } from '@/features/orgs/utils/execPromiseWithErrorToast';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import { useChangePassword } from '@nhost/nextjs';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
import * as Yup from 'yup';
|
||||
|
||||
const validationSchema = Yup.object({
|
||||
newPassword: Yup.string()
|
||||
.label('New Password')
|
||||
.nullable()
|
||||
.required('This field is required.'),
|
||||
confirmPassword: Yup.string()
|
||||
.label('Confirm Password')
|
||||
.nullable()
|
||||
.required('This field is required.')
|
||||
.test(
|
||||
'passwords-match',
|
||||
'Passwords must match.',
|
||||
(value, ctx) => ctx.parent.newPassword === value,
|
||||
),
|
||||
});
|
||||
|
||||
export type PasswordSettingsFormValues = Yup.InferType<typeof validationSchema>;
|
||||
|
||||
export default function PasswordSettings() {
|
||||
const { changePassword } = useChangePassword();
|
||||
const form = useForm<PasswordSettingsFormValues>({
|
||||
reValidateMode: 'onSubmit',
|
||||
resolver: yupResolver(validationSchema),
|
||||
});
|
||||
|
||||
const { register, formState } = form;
|
||||
const isDirty = Object.keys(formState.dirtyFields).length > 0;
|
||||
|
||||
async function handleSubmit(formValues: PasswordSettingsFormValues) {
|
||||
await execPromiseWithErrorToast(
|
||||
async () => {
|
||||
// TODO fix changePassword should throw an error if something happens
|
||||
await changePassword(formValues.newPassword);
|
||||
form.reset();
|
||||
},
|
||||
{
|
||||
loadingMessage: 'Changing password...',
|
||||
successMessage: 'The password has been changed successfully.',
|
||||
errorMessage:
|
||||
'An error occurred while trying to update the password. Please try again.',
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<Form onSubmit={handleSubmit}>
|
||||
<SettingsContainer
|
||||
title="Change Password"
|
||||
description="Update your account password."
|
||||
slotProps={{
|
||||
submitButton: {
|
||||
disabled: !isDirty,
|
||||
loading: formState.isSubmitting,
|
||||
},
|
||||
}}
|
||||
className="grid grid-flow-row lg:grid-cols-5"
|
||||
>
|
||||
<Input
|
||||
{...register('newPassword')}
|
||||
className="col-span-2"
|
||||
type="password"
|
||||
id="new-password"
|
||||
label="New Password"
|
||||
fullWidth
|
||||
helperText={formState.errors.newPassword?.message}
|
||||
error={Boolean(formState.errors.newPassword)}
|
||||
/>
|
||||
|
||||
<Input
|
||||
{...register('confirmPassword')}
|
||||
className="col-span-2 row-start-2"
|
||||
type="password"
|
||||
id="confirm-password"
|
||||
label="Confirm Password"
|
||||
fullWidth
|
||||
helperText={formState.errors.confirmPassword?.message}
|
||||
error={Boolean(formState.errors.confirmPassword)}
|
||||
/>
|
||||
</SettingsContainer>
|
||||
</Form>
|
||||
</FormProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { FormInput } from '@/components/form/FormInput';
|
||||
import { Button } from '@/components/ui/v3/button';
|
||||
import { Form } from '@/components/ui/v3/form';
|
||||
import useChangePasswordForm from '@/features/account/settings/components/PasswordSettings/hooks/useChangePasswordForm';
|
||||
import useOnChangePasswordHandler from '@/features/account/settings/components/PasswordSettings/hooks/useOnChangePasswordHandler';
|
||||
|
||||
export default function PasswordSettings() {
|
||||
const form = useChangePasswordForm();
|
||||
const onSubmit = useOnChangePasswordHandler({
|
||||
onSuccess: () => form.reset(),
|
||||
});
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<div className="rounded-lg border border-[#EAEDF0] bg-white font-['Inter_var'] dark:border-[#2F363D] dark:bg-paper">
|
||||
<div className="flex w-full flex-col items-start gap-4 p-4">
|
||||
<div className="flex w-full flex-col items-start">
|
||||
<h3 className="text-[1.125rem] font-semibold leading-[1.75]">
|
||||
Change Password
|
||||
</h3>
|
||||
<p className="text-[#556378] dark:text-[#A2B3BE]">
|
||||
Update your account password.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex w-[370px] flex-col gap-4">
|
||||
<FormInput
|
||||
control={form.control}
|
||||
name="newPassword"
|
||||
type="password"
|
||||
label="New Password"
|
||||
/>
|
||||
<FormInput
|
||||
control={form.control}
|
||||
name="confirmPassword"
|
||||
type="password"
|
||||
label="Confirm Password"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex w-full items-center justify-end border-t border-[#EAEDF0] px-4 py-2 dark:border-[#2F363D]">
|
||||
<Button type="submit" variant="outline">
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
const validationSchema = z
|
||||
.object({
|
||||
newPassword: z
|
||||
.string({
|
||||
required_error: 'This field is required.',
|
||||
})
|
||||
.min(1, 'This field is required.'),
|
||||
confirmPassword: z
|
||||
.string({
|
||||
required_error: 'This field is required.',
|
||||
})
|
||||
.min(1, 'This field is required.'),
|
||||
})
|
||||
.refine((data) => data.newPassword === data.confirmPassword, {
|
||||
message: 'Passwords must match.',
|
||||
path: ['confirmPassword'],
|
||||
});
|
||||
|
||||
export type ChangePasswordFormValues = z.infer<typeof validationSchema>;
|
||||
|
||||
function useChangePasswordForm() {
|
||||
const form = useForm<ChangePasswordFormValues>({
|
||||
mode: 'onTouched',
|
||||
reValidateMode: 'onBlur',
|
||||
defaultValues: {
|
||||
newPassword: '',
|
||||
confirmPassword: '',
|
||||
},
|
||||
resolver: zodResolver(validationSchema),
|
||||
});
|
||||
|
||||
return form;
|
||||
}
|
||||
|
||||
export default useChangePasswordForm;
|
||||
@@ -0,0 +1,27 @@
|
||||
import useActionWithElevatedPermissions from '@/features/account/settings/hooks/useActionWithElevatedPermissions';
|
||||
import { useChangePassword } from '@nhost/nextjs';
|
||||
import { type ChangePasswordFormValues } from './useChangePasswordForm';
|
||||
|
||||
interface Props {
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
function useOnChangePasswordHandler({ onSuccess }: Props) {
|
||||
const { changePassword: actionFn } = useChangePassword();
|
||||
|
||||
const changePassword = useActionWithElevatedPermissions({
|
||||
actionFn,
|
||||
onSuccess,
|
||||
successMessage: 'The password has been changed successfully.',
|
||||
});
|
||||
|
||||
async function onSubmit(values: ChangePasswordFormValues) {
|
||||
const { newPassword } = values;
|
||||
|
||||
await changePassword(newPassword);
|
||||
}
|
||||
|
||||
return onSubmit;
|
||||
}
|
||||
|
||||
export default useOnChangePasswordHandler;
|
||||
@@ -1,2 +1 @@
|
||||
export * from './PasswordSettings';
|
||||
export { default as PasswordSettings } from './PasswordSettings';
|
||||
export { default as PasswordSettings } from './components/PasswordSettings';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Spinner } from '@/components/ui/v3/spinner';
|
||||
import useGetSecurityKeys from '@/features/account/settings/components/SecurityKeysSettings/hooks/useGetSecurityKeys';
|
||||
import useGetSecurityKeys from '@/features/account/settings/hooks/useGetSecurityKeys';
|
||||
import { InfoAlert } from '@/features/orgs/components/InfoAlert';
|
||||
import { Fingerprint } from 'lucide-react';
|
||||
import { memo } from 'react';
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import useGetSecurityKeys from '@/features/account/settings/components/SecurityKeysSettings/hooks/useGetSecurityKeys';
|
||||
import { getToastStyleProps } from '@/utils/constants/settings';
|
||||
import useActionWithElevatedPermissions from '@/features/account/settings/hooks/useActionWithElevatedPermissions';
|
||||
import useGetSecurityKeys from '@/features/account/settings/hooks/useGetSecurityKeys';
|
||||
import { useAddSecurityKey } from '@nhost/nextjs';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import useElevatedPermissions from './useElevatedPermissions';
|
||||
import { type NewSecurityKeyFormValues } from './useNewSecurityKeyForm';
|
||||
|
||||
interface Props {
|
||||
@@ -10,32 +8,20 @@ interface Props {
|
||||
}
|
||||
|
||||
function useOnAddNewSecurityKeyHandler({ onSuccess }: Props) {
|
||||
const { data, refetch } = useGetSecurityKeys();
|
||||
const { add } = useAddSecurityKey();
|
||||
|
||||
const { elevated, elevatePermissions } = useElevatedPermissions();
|
||||
|
||||
async function requestPermissions() {
|
||||
if (elevated || data?.authUserSecurityKeys.length === 0) {
|
||||
return true;
|
||||
}
|
||||
const isPermissionsElevated = await elevatePermissions();
|
||||
return isPermissionsElevated;
|
||||
}
|
||||
const { refetch } = useGetSecurityKeys();
|
||||
const { add: actionFn } = useAddSecurityKey();
|
||||
const addSecurityKey = useActionWithElevatedPermissions({
|
||||
actionFn,
|
||||
onSuccess: async () => {
|
||||
await refetch();
|
||||
onSuccess();
|
||||
},
|
||||
successMessage: 'Security key has been added.',
|
||||
});
|
||||
|
||||
async function onSubmit(values: NewSecurityKeyFormValues) {
|
||||
const permissionGranted = await requestPermissions();
|
||||
if (!permissionGranted) {
|
||||
return;
|
||||
}
|
||||
const { nickname } = values;
|
||||
|
||||
const { isError, error } = await add(nickname);
|
||||
if (isError) {
|
||||
toast.error(error?.message, getToastStyleProps());
|
||||
}
|
||||
await refetch();
|
||||
onSuccess();
|
||||
await addSecurityKey(nickname);
|
||||
}
|
||||
|
||||
return onSubmit;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import useElevatedPermissions from '@/features/account/settings/hooks/useElevatedPermissions';
|
||||
import useGetSecurityKeys from '@/features/account/settings/hooks/useGetSecurityKeys';
|
||||
import { useRemoveSecurityKeyMutation } from '@/utils/__generated__/graphql';
|
||||
import useElevatedPermissions from './useElevatedPermissions';
|
||||
import useGetSecurityKeys from './useGetSecurityKeys';
|
||||
|
||||
function useRemoveSecurityKey() {
|
||||
const [removeSecurityKeyMutation] = useRemoveSecurityKeyMutation();
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
query getActiveMfaType($id: uuid!) {
|
||||
user(id: $id) {
|
||||
activeMfaType
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import useElevatedPermissions from '@/features/account/settings/hooks/useElevatedPermissions';
|
||||
import useGetSecurityKeys from '@/features/account/settings/hooks/useGetSecurityKeys';
|
||||
import type { AuthErrorPayload } from '@nhost/nextjs';
|
||||
import { toast } from 'react-hot-toast';
|
||||
|
||||
type Action = (...args: any[]) => Promise<ActionResult>;
|
||||
|
||||
type ActionResult = {
|
||||
isError?: boolean;
|
||||
error: AuthErrorPayload;
|
||||
};
|
||||
type UnwrapPromise<T> = T extends Promise<infer U> ? U : T;
|
||||
|
||||
interface Props<Fn extends Action> {
|
||||
actionFn: Fn;
|
||||
onSuccess?: (result: UnwrapPromise<ReturnType<Fn>>) => void;
|
||||
onError?: () => void;
|
||||
successMessage?: string;
|
||||
}
|
||||
|
||||
function useActionWithElevatedPermissions<F extends Action>({
|
||||
actionFn,
|
||||
onSuccess,
|
||||
onError,
|
||||
successMessage,
|
||||
}: Props<F>) {
|
||||
const { elevated, elevatePermissions } = useElevatedPermissions();
|
||||
const { data } = useGetSecurityKeys();
|
||||
|
||||
async function requestPermissions() {
|
||||
if (elevated || data?.authUserSecurityKeys.length === 0) {
|
||||
return true;
|
||||
}
|
||||
const isPermissionsElevated = await elevatePermissions();
|
||||
return isPermissionsElevated;
|
||||
}
|
||||
|
||||
async function actionWithElevatedPermissions(...args: Parameters<F>) {
|
||||
let isSuccess = false;
|
||||
const permissionGranted = await requestPermissions();
|
||||
|
||||
if (!permissionGranted) {
|
||||
return isSuccess;
|
||||
}
|
||||
|
||||
const response = await actionFn(...args);
|
||||
if (response.error) {
|
||||
toast.error(response.error?.message || 'Something went wrong.');
|
||||
onError?.();
|
||||
} else {
|
||||
toast.success(successMessage || 'Success');
|
||||
onSuccess?.(response as UnwrapPromise<ReturnType<F>>);
|
||||
isSuccess = true;
|
||||
}
|
||||
|
||||
return isSuccess;
|
||||
}
|
||||
return actionWithElevatedPermissions;
|
||||
}
|
||||
|
||||
export default useActionWithElevatedPermissions;
|
||||
@@ -8,6 +8,9 @@ function useElevatedPermissions() {
|
||||
const { elevated, elevateEmailSecurityKey } = useElevateSecurityKeyEmail();
|
||||
|
||||
async function elevatePermissions(shouldThrowError = false) {
|
||||
if (elevated) {
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
const response = await elevateEmailSecurityKey(user.email);
|
||||
if (response.isError) {
|
||||
@@ -0,0 +1,26 @@
|
||||
import { MfaOtpForm } from '@/components/common/MfaOtpForm';
|
||||
import type { SendMfaOtpHandler } from '@nhost/nextjs';
|
||||
import { Smartphone } from 'lucide-react';
|
||||
|
||||
interface Props {
|
||||
sendMfaOtp: SendMfaOtpHandler;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
function MfaSignInOtpForm({ sendMfaOtp, loading }: Props) {
|
||||
return (
|
||||
<div className="ws-full relative grid grid-flow-row gap-4 bg-transparent">
|
||||
<div className="flex w-full flex-col items-center justify-center gap-3">
|
||||
<Smartphone size={32} />
|
||||
<h2 className="text-[1.25rem]">Authentication Code</h2>
|
||||
</div>
|
||||
<MfaOtpForm loading={loading} sendMfaOtp={sendMfaOtp} />
|
||||
<p className="text-center">
|
||||
Open your authenticator app or browser extension to view your
|
||||
authentication code.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default MfaSignInOtpForm;
|
||||
@@ -0,0 +1,16 @@
|
||||
import useOnSignUpWithPasswordHandler from '@/features/auth/SignIn/SignInWithEmailAndPassword/hooks/useOnSignInWithEmailAndPasswordHandler';
|
||||
import MfaSignInOtpForm from './MfaSignInOtpForm';
|
||||
import SignInWithEmailAndPasswordForm from './SignInWithEmailAndPasswordForm';
|
||||
|
||||
function SignInWithEmailAndPassword() {
|
||||
const { onSignIWithEmailAndPassword, sendMfaOtp, isLoading, needsMfaOtp } =
|
||||
useOnSignUpWithPasswordHandler();
|
||||
|
||||
return needsMfaOtp ? (
|
||||
<MfaSignInOtpForm sendMfaOtp={sendMfaOtp} loading={isLoading} />
|
||||
) : (
|
||||
<SignInWithEmailAndPasswordForm onSubmit={onSignIWithEmailAndPassword} />
|
||||
);
|
||||
}
|
||||
|
||||
export default SignInWithEmailAndPassword;
|
||||
@@ -0,0 +1,57 @@
|
||||
import { FormInput } from '@/components/form/FormInput';
|
||||
import { Button } from '@/components/ui/v3/button';
|
||||
import { Form } from '@/components/ui/v3/form';
|
||||
import useSignInWithEmailAndPasswordForm, {
|
||||
type SignInWithEmailAndPasswordFormValues,
|
||||
} from '@/features/auth/SignIn/SignInWithEmailAndPassword/hooks/useSignInWithEmailAndPasswordForm';
|
||||
import NextLink from 'next/link';
|
||||
|
||||
interface Props {
|
||||
onSubmit: (values: SignInWithEmailAndPasswordFormValues) => void;
|
||||
}
|
||||
|
||||
function SignInWithEmailAndPassword({ onSubmit }: Props) {
|
||||
const form = useSignInWithEmailAndPasswordForm();
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="grid grid-flow-row gap-4 bg-transparent"
|
||||
>
|
||||
<FormInput
|
||||
control={form.control}
|
||||
label="Email"
|
||||
name="email"
|
||||
type="email"
|
||||
/>
|
||||
<FormInput
|
||||
control={form.control}
|
||||
label="Password"
|
||||
name="password"
|
||||
type="password"
|
||||
/>
|
||||
<NextLink
|
||||
href="/password/new"
|
||||
className="justify-self-start font-semibold"
|
||||
>
|
||||
Forgot password?
|
||||
</NextLink>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="outline"
|
||||
className="w-full !bg-white !text-black disabled:!text-black disabled:!text-opacity-60"
|
||||
>
|
||||
Sign In
|
||||
</Button>
|
||||
<p color="secondary" className="text-center">
|
||||
<span className="text-[#A2B3BE]">or </span>
|
||||
<NextLink className="font-semibold" href="/signin">
|
||||
sign in with GitHub
|
||||
</NextLink>
|
||||
</p>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
export default SignInWithEmailAndPassword;
|
||||
@@ -0,0 +1,50 @@
|
||||
import { getToastStyleProps } from '@/utils/constants/settings';
|
||||
import {
|
||||
useSendVerificationEmail,
|
||||
useSignInEmailPassword,
|
||||
} from '@nhost/nextjs';
|
||||
import { useRouter } from 'next/router';
|
||||
import toast from 'react-hot-toast';
|
||||
import type { SignInWithEmailAndPasswordFormValues } from './useSignInWithEmailAndPasswordForm';
|
||||
|
||||
function useOnSignInWithEmailAndPasswordHandler() {
|
||||
const router = useRouter();
|
||||
const { signInEmailPassword, needsMfaOtp, sendMfaOtp, isLoading } =
|
||||
useSignInEmailPassword();
|
||||
|
||||
const { sendEmail } = useSendVerificationEmail();
|
||||
|
||||
async function onSignIWithEmailAndPassword({
|
||||
email,
|
||||
password,
|
||||
}: SignInWithEmailAndPasswordFormValues) {
|
||||
try {
|
||||
const { needsEmailVerification, error } = await signInEmailPassword(
|
||||
email,
|
||||
password,
|
||||
);
|
||||
if (error) {
|
||||
toast.error(
|
||||
error?.message ||
|
||||
'An error occurred while signing in. Please try again.',
|
||||
getToastStyleProps(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (needsEmailVerification) {
|
||||
await sendEmail(email);
|
||||
router.push(`/email/verify?email=${encodeURIComponent(email)}`);
|
||||
}
|
||||
} catch {
|
||||
toast.error(
|
||||
'An error occurred while signing in. Please try again.',
|
||||
getToastStyleProps(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return { onSignIWithEmailAndPassword, needsMfaOtp, sendMfaOtp, isLoading };
|
||||
}
|
||||
|
||||
export default useOnSignInWithEmailAndPasswordHandler;
|
||||
@@ -0,0 +1,30 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
const validationSchema = z
|
||||
.object({
|
||||
email: z.string().email({ message: 'Invalid email address' }),
|
||||
password: z.string().min(1, { message: 'Password is required' }),
|
||||
})
|
||||
.required();
|
||||
|
||||
export type SignInWithEmailAndPasswordFormValues = z.infer<
|
||||
typeof validationSchema
|
||||
>;
|
||||
|
||||
function useSignInWithEmailAndPasswordForm() {
|
||||
const form = useForm<SignInWithEmailAndPasswordFormValues>({
|
||||
mode: 'onTouched',
|
||||
reValidateMode: 'onBlur',
|
||||
defaultValues: {
|
||||
email: '',
|
||||
password: '',
|
||||
},
|
||||
resolver: zodResolver(validationSchema),
|
||||
});
|
||||
|
||||
return form;
|
||||
}
|
||||
|
||||
export default useSignInWithEmailAndPasswordForm;
|
||||
@@ -0,0 +1 @@
|
||||
export { default as SignInWithEmailAndPassword } from './components/SignInWithEmailAndPassword';
|
||||
@@ -2,24 +2,12 @@ import { getAnonId } from '@/lib/segment';
|
||||
import { getToastStyleProps } from '@/utils/constants/settings';
|
||||
import { useSignUpEmailPassword } from '@nhost/nextjs';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
import type { SignUpWithEmailAndPasswordFormValues } from './useSignUpWithEmailAndPasswordForm';
|
||||
|
||||
function useOnSignUpWithPasswordHandler() {
|
||||
const { signUpEmailPassword, error } = useSignUpEmailPassword();
|
||||
const { signUpEmailPassword } = useSignUpEmailPassword();
|
||||
const router = useRouter();
|
||||
const hasFormBeenSubmitted = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasFormBeenSubmitted.current && error) {
|
||||
toast.error(
|
||||
error?.message ||
|
||||
'An error occurred while signing up. Please try again.',
|
||||
getToastStyleProps(),
|
||||
);
|
||||
}
|
||||
}, [error]);
|
||||
|
||||
async function onSignUpWithPassword({
|
||||
email,
|
||||
@@ -28,7 +16,7 @@ function useOnSignUpWithPasswordHandler() {
|
||||
turnstileToken,
|
||||
}: SignUpWithEmailAndPasswordFormValues) {
|
||||
try {
|
||||
const { needsEmailVerification } = await signUpEmailPassword(
|
||||
const { needsEmailVerification, error } = await signUpEmailPassword(
|
||||
email,
|
||||
password,
|
||||
{
|
||||
@@ -42,6 +30,15 @@ function useOnSignUpWithPasswordHandler() {
|
||||
},
|
||||
);
|
||||
|
||||
if (error) {
|
||||
toast.error(
|
||||
error.message ||
|
||||
'An error occurred while signing up. Please try again.',
|
||||
getToastStyleProps(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (needsEmailVerification) {
|
||||
router.push(`/email/verify?email=${encodeURIComponent(email)}`);
|
||||
}
|
||||
@@ -50,8 +47,6 @@ function useOnSignUpWithPasswordHandler() {
|
||||
'An error occurred while signing up. Please try again.',
|
||||
getToastStyleProps(),
|
||||
);
|
||||
} finally {
|
||||
hasFormBeenSubmitted.current = true;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,24 +2,12 @@ import { getAnonId } from '@/lib/segment';
|
||||
import { getToastStyleProps } from '@/utils/constants/settings';
|
||||
import { useSignUpEmailSecurityKeyEmail } from '@nhost/nextjs';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
import type { SignUpWithSecurityKeyFormValues } from './useSignupWithSecurityKeyForm';
|
||||
|
||||
function useOnSignUpWithSecurityKeyHandler() {
|
||||
const { signUpEmailSecurityKey, error } = useSignUpEmailSecurityKeyEmail();
|
||||
const { signUpEmailSecurityKey } = useSignUpEmailSecurityKeyEmail();
|
||||
const router = useRouter();
|
||||
const hasFormBeenSubmitted = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasFormBeenSubmitted.current && error) {
|
||||
toast.error(
|
||||
error?.message ||
|
||||
'An error occurred while signing up. Please try again.',
|
||||
getToastStyleProps(),
|
||||
);
|
||||
}
|
||||
}, [error]);
|
||||
|
||||
async function onSignUpWithSecurityKey({
|
||||
email,
|
||||
@@ -27,7 +15,7 @@ function useOnSignUpWithSecurityKeyHandler() {
|
||||
turnstileToken,
|
||||
}: SignUpWithSecurityKeyFormValues) {
|
||||
try {
|
||||
const { needsEmailVerification } = await signUpEmailSecurityKey(
|
||||
const { needsEmailVerification, error } = await signUpEmailSecurityKey(
|
||||
email,
|
||||
{
|
||||
displayName,
|
||||
@@ -40,6 +28,15 @@ function useOnSignUpWithSecurityKeyHandler() {
|
||||
},
|
||||
);
|
||||
|
||||
if (error) {
|
||||
toast.error(
|
||||
error.message ||
|
||||
'An error occurred while signing up. Please try again.',
|
||||
getToastStyleProps(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (needsEmailVerification) {
|
||||
router.push(`/email/verify?email=${encodeURIComponent(email)}`);
|
||||
}
|
||||
@@ -48,8 +45,6 @@ function useOnSignUpWithSecurityKeyHandler() {
|
||||
'An error occurred while signing up. Please try again.',
|
||||
getToastStyleProps(),
|
||||
);
|
||||
} finally {
|
||||
hasFormBeenSubmitted.current = true;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Container } from '@/components/layout/Container';
|
||||
import { RetryableErrorBoundary } from '@/components/presentational/RetryableErrorBoundary';
|
||||
import { AccountMfaSettings } from '@/features/account/settings/components/AccountMfaSettings';
|
||||
import { AccountSettingsLayout } from '@/features/account/settings/components/AccountSettingsLayout';
|
||||
import { DeleteAccount } from '@/features/account/settings/components/DeleteAccount';
|
||||
import { DisplayNameSetting } from '@/features/account/settings/components/DisplayNameSetting';
|
||||
@@ -27,6 +28,9 @@ export default function AccountSettingsPage() {
|
||||
<RetryableErrorBoundary>
|
||||
<PasswordSettings />
|
||||
</RetryableErrorBoundary>
|
||||
<RetryableErrorBoundary>
|
||||
<AccountMfaSettings />
|
||||
</RetryableErrorBoundary>
|
||||
<RetryableErrorBoundary>
|
||||
<SecurityKeysSettings />
|
||||
</RetryableErrorBoundary>
|
||||
|
||||
@@ -1,165 +1,29 @@
|
||||
import { NavLink } from '@/components/common/NavLink';
|
||||
import { Form } from '@/components/form/Form';
|
||||
import { UnauthenticatedLayout } from '@/components/layout/UnauthenticatedLayout';
|
||||
import { Box } from '@/components/ui/v2/Box';
|
||||
import { Button } from '@/components/ui/v2/Button';
|
||||
import { Input, inputClasses } from '@/components/ui/v2/Input';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import { getToastStyleProps } from '@/utils/constants/settings';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import { styled } from '@mui/material';
|
||||
import { useNhostClient, useSignInEmailPassword } from '@nhost/nextjs';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useEffect, type ReactElement } from 'react';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import * as Yup from 'yup';
|
||||
|
||||
const validationSchema = Yup.object({
|
||||
email: Yup.string().label('Email').email().required(),
|
||||
password: Yup.string().label('Password').required(),
|
||||
});
|
||||
|
||||
export type EmailSignUpFormValues = Yup.InferType<typeof validationSchema>;
|
||||
|
||||
const StyledInput = styled(Input)({
|
||||
backgroundColor: 'transparent',
|
||||
[`& .${inputClasses.input}`]: {
|
||||
backgroundColor: 'transparent !important',
|
||||
},
|
||||
});
|
||||
|
||||
export default function EmailSignUpPage() {
|
||||
const router = useRouter();
|
||||
const nhost = useNhostClient();
|
||||
const { signInEmailPassword, error } = useSignInEmailPassword();
|
||||
|
||||
const form = useForm<EmailSignUpFormValues>({
|
||||
reValidateMode: 'onSubmit',
|
||||
defaultValues: {
|
||||
email: '',
|
||||
password: '',
|
||||
},
|
||||
resolver: yupResolver(validationSchema),
|
||||
});
|
||||
|
||||
const { register, formState } = form;
|
||||
|
||||
useEffect(() => {
|
||||
if (!error) {
|
||||
return;
|
||||
}
|
||||
|
||||
toast.error(
|
||||
error?.message || 'An error occurred while signing in. Please try again.',
|
||||
getToastStyleProps(),
|
||||
);
|
||||
}, [error]);
|
||||
|
||||
async function handleSubmit({ email, password }: EmailSignUpFormValues) {
|
||||
try {
|
||||
const { needsEmailVerification } = await signInEmailPassword(
|
||||
email,
|
||||
password,
|
||||
);
|
||||
|
||||
if (needsEmailVerification) {
|
||||
await nhost.auth.sendVerificationEmail({ email: email as string });
|
||||
router.push(`/email/verify?email=${email}`);
|
||||
}
|
||||
} catch {
|
||||
toast.error(
|
||||
'An error occurred while signing in. Please try again.',
|
||||
getToastStyleProps(),
|
||||
);
|
||||
}
|
||||
}
|
||||
import { SignInWithEmailAndPassword } from '@/features/auth/SignIn/SignInWithEmailAndPassword';
|
||||
import type { ReactElement } from 'react';
|
||||
|
||||
function SigninPage() {
|
||||
return (
|
||||
<>
|
||||
<Text
|
||||
variant="h2"
|
||||
component="h1"
|
||||
className="text-center text-3.5xl font-semibold lg:text-4.5xl"
|
||||
>
|
||||
<div className="grid gap-12 font-[Inter]">
|
||||
<h1 className="text-center text-3.5xl font-semibold lg:text-4.5xl">
|
||||
Sign In
|
||||
</Text>
|
||||
|
||||
<Box className="grid grid-flow-row gap-4 rounded-md border bg-transparent p-6 lg:p-12">
|
||||
<FormProvider {...form}>
|
||||
<Form
|
||||
onSubmit={handleSubmit}
|
||||
className="grid grid-flow-row gap-4 bg-transparent"
|
||||
>
|
||||
<StyledInput
|
||||
{...register('email')}
|
||||
id="email"
|
||||
placeholder="Email"
|
||||
inputProps={{ min: 2, max: 128 }}
|
||||
spellCheck="false"
|
||||
autoCapitalize="none"
|
||||
type="email"
|
||||
label="Email"
|
||||
hideEmptyHelperText
|
||||
fullWidth
|
||||
error={!!formState.errors.email}
|
||||
helperText={formState.errors.email?.message}
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
<StyledInput
|
||||
{...register('password')}
|
||||
id="password"
|
||||
placeholder="Password"
|
||||
inputProps={{ min: 2, max: 128 }}
|
||||
spellCheck="false"
|
||||
autoCapitalize="none"
|
||||
type="password"
|
||||
label="Password"
|
||||
hideEmptyHelperText
|
||||
fullWidth
|
||||
error={!!formState.errors.password}
|
||||
helperText={formState.errors.password?.message}
|
||||
/>
|
||||
|
||||
<NavLink
|
||||
href="/password/new"
|
||||
color="white"
|
||||
className="justify-self-start font-semibold"
|
||||
>
|
||||
Forgot password?
|
||||
</NavLink>
|
||||
|
||||
<Button
|
||||
className="!bg-white !text-black disabled:!text-black disabled:!text-opacity-60"
|
||||
size="large"
|
||||
disabled={formState.isSubmitting}
|
||||
loading={formState.isSubmitting}
|
||||
type="submit"
|
||||
>
|
||||
Sign In
|
||||
</Button>
|
||||
|
||||
<Text color="secondary" className="text-center">
|
||||
or{' '}
|
||||
<NavLink color="white" className="font-semibold" href="/signin">
|
||||
sign in with GitHub
|
||||
</NavLink>
|
||||
</Text>
|
||||
</Form>
|
||||
</FormProvider>
|
||||
</Box>
|
||||
|
||||
<Text color="secondary" className="text-center text-base lg:text-lg">
|
||||
</h1>
|
||||
<div className="grid grid-flow-row gap-4 rounded-md border bg-transparent p-6 lg:p-12">
|
||||
<SignInWithEmailAndPassword />
|
||||
</div>
|
||||
<p className="text-center text-base lg:text-lg">
|
||||
Don't have an account?{' '}
|
||||
<NavLink href="/signup" color="white">
|
||||
Sign Up
|
||||
</NavLink>
|
||||
</Text>
|
||||
</>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
EmailSignUpPage.getLayout = function getLayout(page: ReactElement) {
|
||||
return <UnauthenticatedLayout title="Sign In">{page}</UnauthenticatedLayout>;
|
||||
SigninPage.getLayout = function getLayout(page: ReactElement) {
|
||||
return <UnauthenticatedLayout title="Sign Up">{page}</UnauthenticatedLayout>;
|
||||
};
|
||||
|
||||
export default SigninPage;
|
||||
|
||||
125
dashboard/src/utils/__generated__/graphql.ts
generated
125
dashboard/src/utils/__generated__/graphql.ts
generated
@@ -27707,13 +27707,6 @@ export type Workspaces_Updates = {
|
||||
where: Workspaces_Bool_Exp;
|
||||
};
|
||||
|
||||
export type RemoveSecurityKeyMutationVariables = Exact<{
|
||||
id: Scalars['uuid'];
|
||||
}>;
|
||||
|
||||
|
||||
export type RemoveSecurityKeyMutation = { __typename?: 'mutation_root', deleteAuthUserSecurityKey?: { __typename?: 'authUserSecurityKeys', id: any } | null };
|
||||
|
||||
export type DeleteUserAccountMutationVariables = Exact<{
|
||||
id: Scalars['uuid'];
|
||||
}>;
|
||||
@@ -27721,6 +27714,13 @@ export type DeleteUserAccountMutationVariables = Exact<{
|
||||
|
||||
export type DeleteUserAccountMutation = { __typename?: 'mutation_root', deleteUser?: { __typename: 'users' } | null };
|
||||
|
||||
export type GetActiveMfaTypeQueryVariables = Exact<{
|
||||
id: Scalars['uuid'];
|
||||
}>;
|
||||
|
||||
|
||||
export type GetActiveMfaTypeQuery = { __typename?: 'query_root', user?: { __typename?: 'users', activeMfaType?: string | null } | null };
|
||||
|
||||
export type GetAuthUserProvidersQueryVariables = Exact<{ [key: string]: never; }>;
|
||||
|
||||
|
||||
@@ -27738,6 +27738,13 @@ export type SecurityKeysQueryVariables = Exact<{
|
||||
|
||||
export type SecurityKeysQuery = { __typename?: 'query_root', authUserSecurityKeys: Array<{ __typename?: 'authUserSecurityKeys', id: any, nickname?: string | null }> };
|
||||
|
||||
export type RemoveSecurityKeyMutationVariables = Exact<{
|
||||
id: Scalars['uuid'];
|
||||
}>;
|
||||
|
||||
|
||||
export type RemoveSecurityKeyMutation = { __typename?: 'mutation_root', deleteAuthUserSecurityKey?: { __typename?: 'authUserSecurityKeys', id: any } | null };
|
||||
|
||||
export type DeletePersonalAccessTokenMutationVariables = Exact<{
|
||||
patId: Scalars['uuid'];
|
||||
}>;
|
||||
@@ -28978,39 +28985,6 @@ export const RunServiceRateLimitFragmentDoc = gql`
|
||||
}
|
||||
}
|
||||
`;
|
||||
export const RemoveSecurityKeyDocument = gql`
|
||||
mutation removeSecurityKey($id: uuid!) {
|
||||
deleteAuthUserSecurityKey(id: $id) {
|
||||
id
|
||||
}
|
||||
}
|
||||
`;
|
||||
export type RemoveSecurityKeyMutationFn = Apollo.MutationFunction<RemoveSecurityKeyMutation, RemoveSecurityKeyMutationVariables>;
|
||||
|
||||
/**
|
||||
* __useRemoveSecurityKeyMutation__
|
||||
*
|
||||
* To run a mutation, you first call `useRemoveSecurityKeyMutation` within a React component and pass it any options that fit your needs.
|
||||
* When your component renders, `useRemoveSecurityKeyMutation` returns a tuple that includes:
|
||||
* - A mutate function that you can call at any time to execute the mutation
|
||||
* - An object with fields that represent the current status of the mutation's execution
|
||||
*
|
||||
* @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2;
|
||||
*
|
||||
* @example
|
||||
* const [removeSecurityKeyMutation, { data, loading, error }] = useRemoveSecurityKeyMutation({
|
||||
* variables: {
|
||||
* id: // value for 'id'
|
||||
* },
|
||||
* });
|
||||
*/
|
||||
export function useRemoveSecurityKeyMutation(baseOptions?: Apollo.MutationHookOptions<RemoveSecurityKeyMutation, RemoveSecurityKeyMutationVariables>) {
|
||||
const options = {...defaultOptions, ...baseOptions}
|
||||
return Apollo.useMutation<RemoveSecurityKeyMutation, RemoveSecurityKeyMutationVariables>(RemoveSecurityKeyDocument, options);
|
||||
}
|
||||
export type RemoveSecurityKeyMutationHookResult = ReturnType<typeof useRemoveSecurityKeyMutation>;
|
||||
export type RemoveSecurityKeyMutationResult = Apollo.MutationResult<RemoveSecurityKeyMutation>;
|
||||
export type RemoveSecurityKeyMutationOptions = Apollo.BaseMutationOptions<RemoveSecurityKeyMutation, RemoveSecurityKeyMutationVariables>;
|
||||
export const DeleteUserAccountDocument = gql`
|
||||
mutation deleteUserAccount($id: uuid!) {
|
||||
deleteUser(id: $id) {
|
||||
@@ -29044,6 +29018,44 @@ export function useDeleteUserAccountMutation(baseOptions?: Apollo.MutationHookOp
|
||||
export type DeleteUserAccountMutationHookResult = ReturnType<typeof useDeleteUserAccountMutation>;
|
||||
export type DeleteUserAccountMutationResult = Apollo.MutationResult<DeleteUserAccountMutation>;
|
||||
export type DeleteUserAccountMutationOptions = Apollo.BaseMutationOptions<DeleteUserAccountMutation, DeleteUserAccountMutationVariables>;
|
||||
export const GetActiveMfaTypeDocument = gql`
|
||||
query getActiveMfaType($id: uuid!) {
|
||||
user(id: $id) {
|
||||
activeMfaType
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
/**
|
||||
* __useGetActiveMfaTypeQuery__
|
||||
*
|
||||
* To run a query within a React component, call `useGetActiveMfaTypeQuery` and pass it any options that fit your needs.
|
||||
* When your component renders, `useGetActiveMfaTypeQuery` returns an object from Apollo Client that contains loading, error, and data properties
|
||||
* you can use to render your UI.
|
||||
*
|
||||
* @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options;
|
||||
*
|
||||
* @example
|
||||
* const { data, loading, error } = useGetActiveMfaTypeQuery({
|
||||
* variables: {
|
||||
* id: // value for 'id'
|
||||
* },
|
||||
* });
|
||||
*/
|
||||
export function useGetActiveMfaTypeQuery(baseOptions: Apollo.QueryHookOptions<GetActiveMfaTypeQuery, GetActiveMfaTypeQueryVariables>) {
|
||||
const options = {...defaultOptions, ...baseOptions}
|
||||
return Apollo.useQuery<GetActiveMfaTypeQuery, GetActiveMfaTypeQueryVariables>(GetActiveMfaTypeDocument, options);
|
||||
}
|
||||
export function useGetActiveMfaTypeLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<GetActiveMfaTypeQuery, GetActiveMfaTypeQueryVariables>) {
|
||||
const options = {...defaultOptions, ...baseOptions}
|
||||
return Apollo.useLazyQuery<GetActiveMfaTypeQuery, GetActiveMfaTypeQueryVariables>(GetActiveMfaTypeDocument, options);
|
||||
}
|
||||
export type GetActiveMfaTypeQueryHookResult = ReturnType<typeof useGetActiveMfaTypeQuery>;
|
||||
export type GetActiveMfaTypeLazyQueryHookResult = ReturnType<typeof useGetActiveMfaTypeLazyQuery>;
|
||||
export type GetActiveMfaTypeQueryResult = Apollo.QueryResult<GetActiveMfaTypeQuery, GetActiveMfaTypeQueryVariables>;
|
||||
export function refetchGetActiveMfaTypeQuery(variables: GetActiveMfaTypeQueryVariables) {
|
||||
return { query: GetActiveMfaTypeDocument, variables: variables }
|
||||
}
|
||||
export const GetAuthUserProvidersDocument = gql`
|
||||
query getAuthUserProviders {
|
||||
authUserProviders {
|
||||
@@ -29164,6 +29176,39 @@ export type SecurityKeysQueryResult = Apollo.QueryResult<SecurityKeysQuery, Secu
|
||||
export function refetchSecurityKeysQuery(variables: SecurityKeysQueryVariables) {
|
||||
return { query: SecurityKeysDocument, variables: variables }
|
||||
}
|
||||
export const RemoveSecurityKeyDocument = gql`
|
||||
mutation removeSecurityKey($id: uuid!) {
|
||||
deleteAuthUserSecurityKey(id: $id) {
|
||||
id
|
||||
}
|
||||
}
|
||||
`;
|
||||
export type RemoveSecurityKeyMutationFn = Apollo.MutationFunction<RemoveSecurityKeyMutation, RemoveSecurityKeyMutationVariables>;
|
||||
|
||||
/**
|
||||
* __useRemoveSecurityKeyMutation__
|
||||
*
|
||||
* To run a mutation, you first call `useRemoveSecurityKeyMutation` within a React component and pass it any options that fit your needs.
|
||||
* When your component renders, `useRemoveSecurityKeyMutation` returns a tuple that includes:
|
||||
* - A mutate function that you can call at any time to execute the mutation
|
||||
* - An object with fields that represent the current status of the mutation's execution
|
||||
*
|
||||
* @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2;
|
||||
*
|
||||
* @example
|
||||
* const [removeSecurityKeyMutation, { data, loading, error }] = useRemoveSecurityKeyMutation({
|
||||
* variables: {
|
||||
* id: // value for 'id'
|
||||
* },
|
||||
* });
|
||||
*/
|
||||
export function useRemoveSecurityKeyMutation(baseOptions?: Apollo.MutationHookOptions<RemoveSecurityKeyMutation, RemoveSecurityKeyMutationVariables>) {
|
||||
const options = {...defaultOptions, ...baseOptions}
|
||||
return Apollo.useMutation<RemoveSecurityKeyMutation, RemoveSecurityKeyMutationVariables>(RemoveSecurityKeyDocument, options);
|
||||
}
|
||||
export type RemoveSecurityKeyMutationHookResult = ReturnType<typeof useRemoveSecurityKeyMutation>;
|
||||
export type RemoveSecurityKeyMutationResult = Apollo.MutationResult<RemoveSecurityKeyMutation>;
|
||||
export type RemoveSecurityKeyMutationOptions = Apollo.BaseMutationOptions<RemoveSecurityKeyMutation, RemoveSecurityKeyMutationVariables>;
|
||||
export const DeletePersonalAccessTokenDocument = gql`
|
||||
mutation DeletePersonalAccessToken($patId: uuid!) {
|
||||
deletePersonalAccessToken: deleteAuthRefreshToken(id: $patId) {
|
||||
|
||||
@@ -19,6 +19,7 @@ export type EnableMfaEvents =
|
||||
code?: string
|
||||
activeMfaType: 'totp'
|
||||
}
|
||||
| { type: 'DISABLE'; code: string }
|
||||
| { type: 'GENERATED' }
|
||||
| { type: 'GENERATED_ERROR'; error: AuthErrorPayload | null }
|
||||
| { type: 'SUCCESS' }
|
||||
@@ -42,11 +43,13 @@ export const createEnableMfaMachine = ({ backendUrl, interpreter }: AuthClient)
|
||||
idle: {
|
||||
initial: 'initial',
|
||||
on: {
|
||||
GENERATE: 'generating'
|
||||
GENERATE: 'generating',
|
||||
DISABLE: 'disabling'
|
||||
},
|
||||
states: {
|
||||
initial: {},
|
||||
error: {}
|
||||
error: {},
|
||||
disabled: {}
|
||||
}
|
||||
},
|
||||
generating: {
|
||||
@@ -77,7 +80,8 @@ export const createEnableMfaMachine = ({ backendUrl, interpreter }: AuthClient)
|
||||
{
|
||||
target: 'activating'
|
||||
}
|
||||
]
|
||||
],
|
||||
DISABLE: '#enableMfa.disabling'
|
||||
},
|
||||
states: { idle: {}, error: {} }
|
||||
},
|
||||
@@ -91,6 +95,14 @@ export const createEnableMfaMachine = ({ backendUrl, interpreter }: AuthClient)
|
||||
},
|
||||
activated: { type: 'final' }
|
||||
}
|
||||
},
|
||||
disabling: {
|
||||
invoke: {
|
||||
src: 'disable',
|
||||
id: 'disable',
|
||||
onDone: { target: 'idle.disabled', actions: 'reportSuccess' },
|
||||
onError: { actions: ['saveError', 'reportError'], target: 'idle.error' }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -105,10 +117,7 @@ export const createEnableMfaMachine = ({ backendUrl, interpreter }: AuthClient)
|
||||
imageUrl: (_, { data: { imageUrl } }: any) => imageUrl,
|
||||
secret: (_, { data: { totpSecret } }: any) => totpSecret
|
||||
}),
|
||||
reportError: send((ctx, event) => {
|
||||
console.log('REPORT', ctx, event)
|
||||
return { type: 'ERROR', error: ctx.error }
|
||||
}),
|
||||
reportError: send((ctx, event) => ({ type: 'ERROR', error: ctx.error })),
|
||||
reportSuccess: send('SUCCESS'),
|
||||
reportGeneratedSuccess: send('GENERATED'),
|
||||
reportGeneratedError: send((ctx) => ({ type: 'GENERATED_ERROR', error: ctx.error }))
|
||||
@@ -130,6 +139,12 @@ export const createEnableMfaMachine = ({ backendUrl, interpreter }: AuthClient)
|
||||
`${backendUrl}/user/mfa`,
|
||||
{ code, activeMfaType },
|
||||
interpreter?.getSnapshot().context.accessToken.value
|
||||
),
|
||||
disable: (_, { code }) =>
|
||||
postFetch(
|
||||
`${backendUrl}/user/mfa`,
|
||||
{ code, activeMfaType: '' },
|
||||
interpreter?.getSnapshot().context.accessToken.value
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,17 +8,24 @@ export interface Typegen0 {
|
||||
data: unknown
|
||||
__tip: 'See the XState TS docs to learn how to strongly type this.'
|
||||
}
|
||||
'done.invoke.disable': {
|
||||
type: 'done.invoke.disable'
|
||||
data: unknown
|
||||
__tip: 'See the XState TS docs to learn how to strongly type this.'
|
||||
}
|
||||
'done.invoke.generate': {
|
||||
type: 'done.invoke.generate'
|
||||
data: unknown
|
||||
__tip: 'See the XState TS docs to learn how to strongly type this.'
|
||||
}
|
||||
'error.platform.activate': { type: 'error.platform.activate'; data: unknown }
|
||||
'error.platform.disable': { type: 'error.platform.disable'; data: unknown }
|
||||
'error.platform.generate': { type: 'error.platform.generate'; data: unknown }
|
||||
'xstate.init': { type: 'xstate.init' }
|
||||
}
|
||||
invokeSrcNameMap: {
|
||||
activate: 'done.invoke.activate'
|
||||
disable: 'done.invoke.disable'
|
||||
generate: 'done.invoke.generate'
|
||||
}
|
||||
missingImplementations: {
|
||||
@@ -28,11 +35,11 @@ export interface Typegen0 {
|
||||
services: never
|
||||
}
|
||||
eventsCausingActions: {
|
||||
reportError: 'error.platform.activate'
|
||||
reportError: 'error.platform.activate' | 'error.platform.disable'
|
||||
reportGeneratedError: 'error.platform.generate'
|
||||
reportGeneratedSuccess: 'done.invoke.generate'
|
||||
reportSuccess: 'done.invoke.activate'
|
||||
saveError: 'error.platform.activate' | 'error.platform.generate'
|
||||
reportSuccess: 'done.invoke.activate' | 'done.invoke.disable'
|
||||
saveError: 'error.platform.activate' | 'error.platform.disable' | 'error.platform.generate'
|
||||
saveGeneration: 'done.invoke.generate'
|
||||
saveInvalidMfaCodeError: 'ACTIVATE'
|
||||
saveInvalidMfaTypeError: 'ACTIVATE'
|
||||
@@ -44,9 +51,11 @@ export interface Typegen0 {
|
||||
}
|
||||
eventsCausingServices: {
|
||||
activate: 'ACTIVATE'
|
||||
disable: 'DISABLE'
|
||||
generate: 'GENERATE'
|
||||
}
|
||||
matchesStates:
|
||||
| 'disabling'
|
||||
| 'generated'
|
||||
| 'generated.activated'
|
||||
| 'generated.activating'
|
||||
@@ -55,11 +64,88 @@ export interface Typegen0 {
|
||||
| 'generated.idle.idle'
|
||||
| 'generating'
|
||||
| 'idle'
|
||||
| 'idle.disabled'
|
||||
| 'idle.error'
|
||||
| 'idle.initial'
|
||||
| {
|
||||
generated?: 'activated' | 'activating' | 'idle' | { idle?: 'error' | 'idle' }
|
||||
idle?: 'error' | 'initial'
|
||||
idle?: 'disabled' | 'error' | 'initial'
|
||||
}
|
||||
tags: never
|
||||
}
|
||||
|
||||
// This file was automatically generated. Edits will be overwritten
|
||||
|
||||
export interface Typegen0 {
|
||||
'@@xstate/typegen': true
|
||||
internalEvents: {
|
||||
'done.invoke.activate': {
|
||||
type: 'done.invoke.activate'
|
||||
data: unknown
|
||||
__tip: 'See the XState TS docs to learn how to strongly type this.'
|
||||
}
|
||||
'done.invoke.disable': {
|
||||
type: 'done.invoke.disable'
|
||||
data: unknown
|
||||
__tip: 'See the XState TS docs to learn how to strongly type this.'
|
||||
}
|
||||
'done.invoke.generate': {
|
||||
type: 'done.invoke.generate'
|
||||
data: unknown
|
||||
__tip: 'See the XState TS docs to learn how to strongly type this.'
|
||||
}
|
||||
'error.platform.activate': { type: 'error.platform.activate'; data: unknown }
|
||||
'error.platform.disable': { type: 'error.platform.disable'; data: unknown }
|
||||
'error.platform.generate': { type: 'error.platform.generate'; data: unknown }
|
||||
'xstate.init': { type: 'xstate.init' }
|
||||
}
|
||||
invokeSrcNameMap: {
|
||||
activate: 'done.invoke.activate'
|
||||
disable: 'done.invoke.disable'
|
||||
generate: 'done.invoke.generate'
|
||||
}
|
||||
missingImplementations: {
|
||||
actions: never
|
||||
delays: never
|
||||
guards: never
|
||||
services: never
|
||||
}
|
||||
eventsCausingActions: {
|
||||
reportError: 'error.platform.activate' | 'error.platform.disable'
|
||||
reportGeneratedError: 'error.platform.generate'
|
||||
reportGeneratedSuccess: 'done.invoke.generate'
|
||||
reportSuccess: 'done.invoke.activate' | 'done.invoke.disable'
|
||||
saveError: 'error.platform.activate' | 'error.platform.disable' | 'error.platform.generate'
|
||||
saveGeneration: 'done.invoke.generate'
|
||||
saveInvalidMfaCodeError: 'ACTIVATE'
|
||||
saveInvalidMfaTypeError: 'ACTIVATE'
|
||||
}
|
||||
eventsCausingDelays: {}
|
||||
eventsCausingGuards: {
|
||||
invalidMfaCode: 'ACTIVATE'
|
||||
invalidMfaType: 'ACTIVATE'
|
||||
}
|
||||
eventsCausingServices: {
|
||||
activate: 'ACTIVATE'
|
||||
disable: 'DISABLE'
|
||||
generate: 'GENERATE'
|
||||
}
|
||||
matchesStates:
|
||||
| 'disabling'
|
||||
| 'generated'
|
||||
| 'generated.activated'
|
||||
| 'generated.activating'
|
||||
| 'generated.idle'
|
||||
| 'generated.idle.error'
|
||||
| 'generated.idle.idle'
|
||||
| 'generating'
|
||||
| 'idle'
|
||||
| 'idle.disabled'
|
||||
| 'idle.error'
|
||||
| 'idle.initial'
|
||||
| {
|
||||
generated?: 'activated' | 'activating' | 'idle' | { idle?: 'error' | 'idle' }
|
||||
idle?: 'disabled' | 'error' | 'initial'
|
||||
}
|
||||
tags: never
|
||||
}
|
||||
|
||||
@@ -7,15 +7,21 @@ import { AuthActionErrorState } from './types'
|
||||
export interface GenerateQrCodeHandlerResult extends AuthActionErrorState {
|
||||
qrCodeDataUrl: string
|
||||
isGenerated: boolean
|
||||
totpSecret: string | null
|
||||
}
|
||||
|
||||
export interface GenerateQrCodeState extends GenerateQrCodeHandlerResult {
|
||||
isGenerating: boolean
|
||||
}
|
||||
|
||||
export interface ActivateMfaHandlerResult extends AuthActionErrorState {
|
||||
isActivated: boolean
|
||||
}
|
||||
|
||||
export interface DisableMfaHandlerResult extends AuthActionErrorState {
|
||||
isDisabled: boolean
|
||||
}
|
||||
|
||||
export interface ActivateMfaState extends ActivateMfaHandlerResult {
|
||||
isActivating: boolean
|
||||
}
|
||||
@@ -29,18 +35,21 @@ export const generateQrCodePromise = (service: InterpreterFrom<EnableMfadMachine
|
||||
error: null,
|
||||
isError: false,
|
||||
isGenerated: true,
|
||||
qrCodeDataUrl: state.context.imageUrl || ''
|
||||
qrCodeDataUrl: state.context.imageUrl || '',
|
||||
totpSecret: state.context.secret
|
||||
})
|
||||
} else if (state.matches({ idle: 'error' })) {
|
||||
resolve({
|
||||
error: state.context.error || null,
|
||||
isError: true,
|
||||
isGenerated: false,
|
||||
qrCodeDataUrl: ''
|
||||
qrCodeDataUrl: '',
|
||||
totpSecret: state.context.secret
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
export const activateMfaPromise = (service: InterpreterFrom<EnableMfadMachine>, code: string) =>
|
||||
new Promise<ActivateMfaHandlerResult>((resolve) => {
|
||||
service.send('ACTIVATE', {
|
||||
@@ -55,3 +64,15 @@ export const activateMfaPromise = (service: InterpreterFrom<EnableMfadMachine>,
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
export const disableMfaPromise = (service: InterpreterFrom<EnableMfadMachine>, code: string) =>
|
||||
new Promise<DisableMfaHandlerResult>((resolve) => {
|
||||
service.send('DISABLE', { code })
|
||||
service.onTransition((state) => {
|
||||
if (state.matches({ idle: 'disabled' })) {
|
||||
resolve({ error: null, isDisabled: true, isError: false })
|
||||
} else if (state.matches({ idle: 'error' })) {
|
||||
resolve({ error: state.context.error, isDisabled: false, isError: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
export type { ErrorPayload, NhostSession, Subdomain, User } from '@nhost/nhost-js'
|
||||
export type {
|
||||
ErrorPayload,
|
||||
NhostSession,
|
||||
Subdomain,
|
||||
User,
|
||||
ActivateMfaHandlerResult,
|
||||
DisableMfaHandlerResult,
|
||||
AuthErrorPayload
|
||||
} from '@nhost/nhost-js'
|
||||
export * from './client'
|
||||
export * from './components'
|
||||
export * from './provider'
|
||||
|
||||
@@ -3,6 +3,8 @@ import {
|
||||
activateMfaPromise,
|
||||
ActivateMfaState,
|
||||
createEnableMfaMachine,
|
||||
DisableMfaHandlerResult,
|
||||
disableMfaPromise,
|
||||
GenerateQrCodeHandlerResult,
|
||||
generateQrCodePromise,
|
||||
GenerateQrCodeState
|
||||
@@ -14,6 +16,8 @@ import { useNhostClient } from './useNhostClient'
|
||||
interface ConfigMfaState extends ActivateMfaState, GenerateQrCodeState {
|
||||
generateQrCode: () => Promise<GenerateQrCodeHandlerResult>
|
||||
activateMfa: (code: string) => Promise<ActivateMfaHandlerResult>
|
||||
disableMfa: (code: string) => Promise<DisableMfaHandlerResult>
|
||||
isDisabling: boolean
|
||||
}
|
||||
|
||||
// TODO documentation when available in Nhost Cloud - see changelog
|
||||
@@ -31,12 +35,15 @@ export const useConfigMfa = (): ConfigMfaState => {
|
||||
const isGenerated = useSelector(service, (state) => state.matches('generated'))
|
||||
const isActivating = useSelector(service, (state) => state.matches({ generated: 'activating' }))
|
||||
const isActivated = useSelector(service, (state) => state.matches({ generated: 'activated' }))
|
||||
const isDisabling = useSelector(service, (state) => state.matches('disabling'))
|
||||
const error = useSelector(service, (state) => state.context.error)
|
||||
const qrCodeDataUrl = useSelector(service, (state) => state.context.imageUrl || '')
|
||||
const totpSecret = useSelector(service, (state) => state.context.secret || '')
|
||||
|
||||
const generateQrCode = () => generateQrCodePromise(service)
|
||||
|
||||
const activateMfa = (code: string) => activateMfaPromise(service, code)
|
||||
const disableMfa = (code: string) => disableMfaPromise(service, code)
|
||||
|
||||
return {
|
||||
generateQrCode,
|
||||
@@ -46,7 +53,10 @@ export const useConfigMfa = (): ConfigMfaState => {
|
||||
activateMfa,
|
||||
isActivating,
|
||||
isActivated,
|
||||
isDisabling,
|
||||
isError,
|
||||
error
|
||||
error,
|
||||
disableMfa,
|
||||
totpSecret
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,13 +12,13 @@ interface SignInEmailPasswordHandler {
|
||||
(email: string, password: string): Promise<SignInEmailPasswordHandlerResult>
|
||||
}
|
||||
|
||||
interface SendMfaOtpHander {
|
||||
export interface SendMfaOtpHandler {
|
||||
(otp: string): Promise<SignInMfaTotpHandlerResult>
|
||||
}
|
||||
|
||||
export interface SignInEmailPasswordHookResult extends SignInEmailPasswordState {
|
||||
signInEmailPassword: SignInEmailPasswordHandler
|
||||
sendMfaOtp: SendMfaOtpHander
|
||||
sendMfaOtp: SendMfaOtpHandler
|
||||
}
|
||||
|
||||
interface SignInEmailPasswordHook {
|
||||
@@ -49,7 +49,7 @@ export const useSignInEmailPassword: SignInEmailPasswordHook = () => {
|
||||
const signInEmailPassword: SignInEmailPasswordHandler = (email, password) =>
|
||||
signInEmailPasswordPromise(service, email, password)
|
||||
|
||||
const sendMfaOtp: SendMfaOtpHander = (otp) => signInMfaTotpPromise(service, otp)
|
||||
const sendMfaOtp: SendMfaOtpHandler = (otp) => signInMfaTotpPromise(service, otp)
|
||||
|
||||
const user = useSelector(
|
||||
service,
|
||||
@@ -84,11 +84,9 @@ export const useSignInEmailPassword: SignInEmailPasswordHook = () => {
|
||||
}),
|
||||
(a, b) => a === b
|
||||
)
|
||||
const needsMfaOtp = useSelector(
|
||||
service,
|
||||
(state) => state.matches({ authentication: { signedOut: 'needsMfa' } }),
|
||||
(a, b) => a === b
|
||||
)
|
||||
|
||||
const needsMfaOtp = useSelector(service, (state) => state.context.mfa !== null)
|
||||
|
||||
const isError = useSelector(
|
||||
service,
|
||||
(state) => state.matches({ authentication: { signedOut: 'failed' } }),
|
||||
|
||||
@@ -41,6 +41,7 @@ export const useConfigMfa = (): ConfigMfaComposableState => {
|
||||
const isActivated = useSelector(service, (state) => state.matches({ generated: 'activated' }))
|
||||
const error = useSelector(service, (state) => state.context.error)
|
||||
const qrCodeDataUrl = useSelector(service, (state) => state.context.imageUrl || '')
|
||||
const totpSecret = useSelector(service, (state) => state.context.secret || '')
|
||||
|
||||
const generateQrCode = () => generateQrCodePromise(service)
|
||||
|
||||
@@ -55,6 +56,7 @@ export const useConfigMfa = (): ConfigMfaComposableState => {
|
||||
isActivating,
|
||||
isActivated,
|
||||
isError,
|
||||
error
|
||||
error,
|
||||
totpSecret
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user