mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 19:12:50 +00:00
feat: implement two-factor authentication flow during sign-in and add utility hooks for configuration management
This commit is contained in:
@@ -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 {
|
||||
|
||||
22
libs/auth/src/lib/hooks/two-factor-request.spec.ts
Normal file
22
libs/auth/src/lib/hooks/two-factor-request.spec.ts
Normal 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 },
|
||||
});
|
||||
});
|
||||
});
|
||||
26
libs/auth/src/lib/hooks/two-factor-request.ts
Normal file
26
libs/auth/src/lib/hooks/two-factor-request.ts
Normal 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 },
|
||||
};
|
||||
}
|
||||
21
libs/auth/src/lib/hooks/useTwoFactor.ts
Normal file
21
libs/auth/src/lib/hooks/useTwoFactor.ts
Normal 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 };
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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'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'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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user