feat: implement two-factor authentication flow during sign-in and add utility hooks for configuration management

This commit is contained in:
estifanos
2026-08-19 10:42:30 +00:00
parent 6117f9c862
commit ff89b9af5e
11 changed files with 170 additions and 38 deletions

View File

@@ -27,6 +27,7 @@ export {
} from "./lib/store/signup.slice";
export { usePermissions } from "./lib/hooks/usePermissions";
export { useAuthToken } from "./lib/hooks/useAuthToken";
export { useTwoFactor } from "./lib/hooks/useTwoFactor";
export type { PermissionSet } from "./lib/hooks/usePermissions";
export { RequirePermission } from "./lib/components/RequirePermission";
export {

View File

@@ -0,0 +1,22 @@
import { describe, expect, it } from "vitest";
import { twoFactorRequest } from "./two-factor-request";
describe("twoFactorRequest", () => {
it("creates when the user has no account configuration yet", () => {
expect(twoFactorRequest(undefined, true)).toEqual({
url: "/account-configurations/set-my-config",
method: "POST",
body: { isMFARequired: true },
});
});
it("updates an existing record instead of creating a second one", () => {
const config = { id: "c9fc67c6", isMFARequired: true };
expect(twoFactorRequest(config, false)).toEqual({
url: "/account-configurations/my-config/c9fc67c6",
method: "PUT",
body: { isMFARequired: false },
});
});
});

View File

@@ -0,0 +1,26 @@
type AccountConfig = { id: string; isMFARequired: boolean };
/**
* Picks the request that persists the two-step verification setting.
*
* `set-my-config` only ever creates, and `iam.account_configurations` is unique
* per user — so an existing record has to be updated through PUT. Getting this
* backwards works exactly once and then fails on the unique constraint, which
* is why the choice lives here, apart from the hook, with a test on it.
*/
export function twoFactorRequest(
config: AccountConfig | undefined,
isMFARequired: boolean,
) {
return config
? {
url: `/account-configurations/my-config/${config.id}`,
method: "PUT" as const,
body: { isMFARequired },
}
: {
url: "/account-configurations/set-my-config",
method: "POST" as const,
body: { isMFARequired },
};
}

View File

@@ -0,0 +1,21 @@
import { useApiMutation, useApiQuery } from "@ema-platform/api";
import { twoFactorRequest } from "./two-factor-request";
type AccountConfig = { id: string; isMFARequired: boolean };
/** Reads and writes the signed-in user's IAM two-step verification setting. */
export function useTwoFactor() {
const { data, refetch, isLoading } = useApiQuery<{ items: AccountConfig[] }>({
url: "/account-configurations/my-config",
});
const [save, { isLoading: isSaving }] = useApiMutation();
const config = data?.items?.[0];
const setEnabled = async (isMFARequired: boolean) => {
await save(twoFactorRequest(config, isMFARequired)).unwrap();
await refetch();
};
return { enabled: !!config?.isMFARequired, isLoading, isSaving, setEnabled };
}

View File

@@ -111,6 +111,17 @@ export function LoginPage() {
method: "POST",
body: values,
}).unwrap();
// Two-step verification on: the server withheld the tokens and mailed a
// one-time code instead. Storing this response would write an undefined
// token and 401 the very next request.
if (data.mfaRequired) {
navigate("/otp-verify", {
state: { mode: "mfa", email: values.email },
});
return;
}
dispatch(loginSuccess(data));
const me = await meTrigger({

View File

@@ -17,10 +17,13 @@ import { Controller, useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { useNavigate, useLocation } from 'react-router-dom';
import { useDispatch } from 'react-redux';
import { useApiMutation } from '@ema-platform/api';
import { notify, useErrorHandler } from '@ema-platform/ui';
import { AuthShell } from '../components/AuthShell';
import { useAuthConfig } from '../AuthConfig';
import { loginSuccess, setUser } from '../store/auth.slice';
import type { AuthUser, LoginPayload } from '../types/auth.types';
const CODE_LENGTH = 6;
const RESEND_SECONDS = 30;
@@ -37,14 +40,18 @@ export function OTPVerificationPage() {
const navigate = useNavigate();
const location = useLocation();
const { loginRedirectPath } = useAuthConfig();
const dispatch = useDispatch();
const state = location.state as
| { email?: string; phoneNumber?: string }
| { email?: string; phoneNumber?: string; mode?: 'mfa' }
| null;
const email = state?.email ?? '';
const phoneNumber = state?.phoneNumber ?? '';
/** Second factor at sign-in, as opposed to the phone-number verification. */
const isMfa = state?.mode === 'mfa';
const [verifyTrigger, { isLoading: loading }] = useApiMutation();
const [verifyTrigger, { isLoading: loading }] = useApiMutation<LoginPayload>();
const [resendTrigger, { isLoading: resending }] = useApiMutation();
const [meTrigger] = useApiMutation<AuthUser>();
const [secondsLeft, setSecondsLeft] = useState(RESEND_SECONDS);
const [serverError, setServerError] = useState<string | null>(null);
const { handleError } = useErrorHandler();
@@ -66,6 +73,21 @@ export function OTPVerificationPage() {
const onSubmit = async (values: FormValues) => {
try {
if (isMfa) {
const data = await verifyTrigger({
url: '/auth/mfa-verify',
method: 'POST',
body: { email, otp: values.verificationCode },
}).unwrap();
dispatch(loginSuccess(data));
const me = await meTrigger({ url: '/auth/me', method: 'GET' }).unwrap();
dispatch(setUser(me));
navigate(loginRedirectPath);
return;
}
await verifyTrigger({
url: '/auth/verify-phone-number',
method: 'PATCH',
@@ -164,44 +186,51 @@ export function OTPVerificationPage() {
</Stack>
</form>
<Divider
label="Having trouble?"
labelPosition="center"
variant="dashed"
/>
{/* Sign-in has not happened yet under MFA, so there is nothing to skip
to — and the resend endpoint below only regenerates phone-verification
codes. A fresh MFA code means logging in again. */}
{!isMfa && (
<>
<Divider
label="Having trouble?"
labelPosition="center"
variant="dashed"
/>
<Button
variant="light"
fullWidth
size="md"
onClick={() => navigate(loginRedirectPath)}
>
Skip verification for now
</Button>
<Button
variant="light"
fullWidth
size="md"
onClick={() => navigate(loginRedirectPath)}
>
Skip verification for now
</Button>
<Center>
<Group justify="center" gap={6}>
<Text size="sm" c="dimmed">
Didn&apos;t receive a code?
</Text>
{secondsLeft > 0 ? (
<Text size="sm" c="dimmed" fw={600}>
Resend in {secondsLeft}s
</Text>
) : (
<Anchor
size="sm"
fw={600}
onClick={handleResendOtp}
style={
resending ? { pointerEvents: 'none', opacity: 0.6 } : undefined
}
>
Resend code
</Anchor>
)}
</Group>
</Center>
<Center>
<Group justify="center" gap={6}>
<Text size="sm" c="dimmed">
Didn&apos;t receive a code?
</Text>
{secondsLeft > 0 ? (
<Text size="sm" c="dimmed" fw={600}>
Resend in {secondsLeft}s
</Text>
) : (
<Anchor
size="sm"
fw={600}
onClick={handleResendOtp}
style={
resending ? { pointerEvents: 'none', opacity: 0.6 } : undefined
}
>
Resend code
</Anchor>
)}
</Group>
</Center>
</>
)}
</Stack>
</AuthShell>
);