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>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; </dd></td>
<td><a
href="https://github.com/nhost/nhost/pull/3342/files#diff-88ee3610a0658d5eead85db025a5e91e74a4d2f2a836adf7eb44ff80888a613b">+61/-0</a>&nbsp;
&nbsp; </td>

</tr>

<tr>
<td><strong>index.ts</strong><dd><code>Export MfaOtpForm
component</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; </dd></td>
<td><a
href="https://github.com/nhost/nhost/pull/3342/files#diff-9c1deb50c3a92ca5494be705635984a97e1b41b07cd0847168a4eeddf0e375d0">+1/-0</a>&nbsp;
&nbsp; &nbsp; </td>

</tr>

<tr>
<td><strong>AccountMfaSettings.tsx</strong><dd><code>Implement MFA
settings component</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
</dd></td>
<td><a
href="https://github.com/nhost/nhost/pull/3342/files#diff-4eb33e0f23780eaf93fd7d86850b263d83b05dc2d7a3f6ed9e30d1ca811f17af">+32/-0</a>&nbsp;
&nbsp; </td>

</tr>

<tr>
<td><strong>GenerateMfaQRCodeButton.tsx</strong><dd><code>Create button
to generate MFA QR code</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; </dd></td>
<td><a
href="https://github.com/nhost/nhost/pull/3342/files#diff-7310648a5e879bb76ba6c3136fe555ed3bbdacddc33eef4ce8fc9c21a547ec82">+50/-0</a>&nbsp;
&nbsp; </td>

</tr>

<tr>
<td><strong>MfaQRCode.tsx</strong><dd><code>Implement MFA QR code
generation component</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
</dd></td>
<td><a
href="https://github.com/nhost/nhost/pull/3342/files#diff-0c60d61f12b47e421c67c389c66399da76af4b32241610fe94c6635353e57da2">+49/-0</a>&nbsp;
&nbsp; </td>

</tr>

<tr>
<td><strong>useActivateMfa.ts</strong><dd><code>Create hook for MFA
activation</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
</dd></td>
<td><a
href="https://github.com/nhost/nhost/pull/3342/files#diff-0ae70fc9df5a3a6828f7a266db8036107ce9ea705cd318d3a1c4b7304d8522ba">+46/-0</a>&nbsp;
&nbsp; </td>

</tr>

<tr>
<td><strong>useMfaEnabled.ts</strong><dd><code>Create hook to check MFA
status</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </dd></td>
<td><a
href="https://github.com/nhost/nhost/pull/3342/files#diff-31d2af339a8dd32beff8cce79962fa0dd23b6c89687b21aa75663ebeccb0b154">+17/-0</a>&nbsp;
&nbsp; </td>

</tr>

<tr>
<td><strong>index.ts</strong><dd><code>Export AccountMfaSettings
component</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; </dd></td>
<td><a
href="https://github.com/nhost/nhost/pull/3342/files#diff-8c530fc016dd3569f2b7ec7e9085b99c99922ed077357bec562b8c9acaead24a">+1/-0</a>&nbsp;
&nbsp; &nbsp; </td>

</tr>

<tr>
<td><strong>SecurityKeyList.tsx</strong><dd><code>Update import path for
useGetSecurityKeys</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
</dd></td>
<td><a
href="https://github.com/nhost/nhost/pull/3342/files#diff-966a157d381be33bc876e76b28f804e80cae6edb1aa088e78f883063966be3ba">+1/-1</a>&nbsp;
&nbsp; &nbsp; </td>

</tr>

<tr>
<td><strong>useOnAddNewSecurityKeyHandler.ts</strong><dd><code>Update
import paths for hooks</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; </dd></td>
<td><a
href="https://github.com/nhost/nhost/pull/3342/files#diff-3514a6d1514269a83f37fc25e9cb24add9d5d74f9cf3341293c0e0f2a4c2e286">+2/-2</a>&nbsp;
&nbsp; &nbsp; </td>

</tr>

<tr>
<td><strong>useRemoveSecurityKey.ts</strong><dd><code>Update import
paths for hooks</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; </dd></td>
<td><a
href="https://github.com/nhost/nhost/pull/3342/files#diff-5683e00a14f39018d8fe58a3116c2a8ea6d2f2a83abb2177bbf0ee8ddf0f97b5">+2/-2</a>&nbsp;
&nbsp; &nbsp; </td>

</tr>

<tr>
<td><strong>useElevatedPermissions.ts</strong><dd><code>Create hook for
elevated permissions</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </dd></td>
<td><a
href="https://github.com/nhost/nhost/pull/3342/files#diff-c1e4f573300c771149cc2e59918c9acf2ae5f8a6680800a899707c70800ba144">[link]</a>&nbsp;
&nbsp; </td>

</tr>

<tr>
<td><strong>useGetSecurityKeys.ts</strong><dd><code>Create hook to fetch
security keys</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </dd></td>
<td><a
href="https://github.com/nhost/nhost/pull/3342/files#diff-1f9fed870cab61f15e304342e4913edab0f5537eeb6230070de4b4f7173fa138">[link]</a>&nbsp;
&nbsp; </td>

</tr>

<tr>
<td><strong>MfaSignInOtpForm.tsx</strong><dd><code>Create MFA sign-in
OTP form component</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; </dd></td>
<td><a
href="https://github.com/nhost/nhost/pull/3342/files#diff-91eba232beb0543b1e972ed9a21a0be797ed94b720487834bb3316a5dbd732f5">+26/-0</a>&nbsp;
&nbsp; </td>

</tr>

<tr>
<td><strong>SignInWithEmailAndPassword.tsx</strong><dd><code>Update
sign-in component to support MFA</code>&nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; </dd></td>
<td><a
href="https://github.com/nhost/nhost/pull/3342/files#diff-a2b70644663baf4f6f2cdffd846d4d743a5ca1f2a64c4b278b6f04c6c5c92161">+16/-0</a>&nbsp;
&nbsp; </td>

</tr>

<tr>

<td><strong>SignInWithEmailAndPasswordForm.tsx</strong><dd><code>Implement
sign-in form with MFA support</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; </dd></td>
<td><a
href="https://github.com/nhost/nhost/pull/3342/files#diff-a07fd6bd20c97d0c9c875e690cd3a80068fc58f74d3579feb210e189d32f5031">+91/-0</a>&nbsp;
&nbsp; </td>

</tr>

<tr>

<td><strong>useOnSignInWithEmailAndPasswordHandler.ts</strong><dd><code>Create
hook for sign-in with MFA</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
</dd></td>
<td><a
href="https://github.com/nhost/nhost/pull/3342/files#diff-1a253bfc02c3267ab1c6b58c07aa06142b7e711d613b672c8420ff2861b12d27">+56/-0</a>&nbsp;
&nbsp; </td>

</tr>

<tr>

<td><strong>useSignInWithEmailAndPasswordForm.ts</strong><dd><code>Create
hook for sign-in form validation</code>&nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; </dd></td>
<td><a
href="https://github.com/nhost/nhost/pull/3342/files#diff-b908e474c0fb54db9c922d9fef7cf1ef6c4ccb0dd7519da0c45a18e5bb26ed40">+30/-0</a>&nbsp;
&nbsp; </td>

</tr>

<tr>
<td><strong>index.ts</strong><dd><code>Export SignInWithEmailAndPassword
component</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </dd></td>
<td><a
href="https://github.com/nhost/nhost/pull/3342/files#diff-d3fd195b5ca8ece9eac446129e8501793e5bd6e5c167ed36c8c6d0adc1723fda">+1/-0</a>&nbsp;
&nbsp; &nbsp; </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>&nbsp;
&nbsp; &nbsp; </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>&nbsp;
&nbsp; &nbsp; </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>&nbsp;
&nbsp; &nbsp; </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>&nbsp;
</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>&nbsp;
&nbsp; &nbsp; </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>&nbsp;
&nbsp; &nbsp; </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>&nbsp;
&nbsp; &nbsp; </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:
robertkasza
2025-06-05 11:38:49 +02:00
committed by GitHub
parent 4b8478004e
commit 39b10a2e9f
48 changed files with 1083 additions and 450 deletions

View File

@@ -0,0 +1,7 @@
---
'@nhost/hasura-auth-js': minor
'@nhost/react': minor
'@nhost/dashboard': minor
---
feat (dashboard): Add multi-factor authentication

View 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;

View File

@@ -0,0 +1 @@
export { default as MfaOtpForm } from './MfaOtpForm';

View File

@@ -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 {

View File

@@ -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;

View File

@@ -0,0 +1 @@
export { default as CopyToClipboardButton } from './CopyToClipboardButton';

View File

@@ -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;

View File

@@ -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;

View File

@@ -0,0 +1 @@
export { default as DisableMfaButton } from './DisableMfaButton';

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -0,0 +1 @@
export { default as EnableMfaButton } from './EnableMfaButton';

View File

@@ -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;

View File

@@ -0,0 +1 @@
export { default as AccountMfaSettings } from './components/AccountMfaSettings';

View File

@@ -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) {

View File

@@ -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();

View File

@@ -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 (

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -1,2 +1 @@
export * from './PasswordSettings';
export { default as PasswordSettings } from './PasswordSettings';
export { default as PasswordSettings } from './components/PasswordSettings';

View File

@@ -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';

View File

@@ -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;

View File

@@ -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();

View File

@@ -0,0 +1,5 @@
query getActiveMfaType($id: uuid!) {
user(id: $id) {
activeMfaType
}
}

View File

@@ -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;

View File

@@ -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) {

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -0,0 +1 @@
export { default as SignInWithEmailAndPassword } from './components/SignInWithEmailAndPassword';

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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>

View File

@@ -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&apos;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;

View File

@@ -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) {

View File

@@ -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
)
}
}

View File

@@ -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
}

View File

@@ -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 })
}
})
})

View File

@@ -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'

View File

@@ -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
}
}

View File

@@ -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' } }),

View File

@@ -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
}
}