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:
@@ -457,6 +457,7 @@ export const am: Translations = {
|
|||||||
twoStep: {
|
twoStep: {
|
||||||
title: "ባለሁለት ደረጃ ማረጋገጫ",
|
title: "ባለሁለት ደረጃ ማረጋገጫ",
|
||||||
desc: "በየጊዜው ሲገቡ ከስልክዎ የአንድ ጊዜ ኮድ ያስፈልጋል።",
|
desc: "በየጊዜው ሲገቡ ከስልክዎ የአንድ ጊዜ ኮድ ያስፈልጋል።",
|
||||||
|
saved: "ባለሁለት ደረጃ ማረጋገጫ ተዘምኗል",
|
||||||
},
|
},
|
||||||
layout: {
|
layout: {
|
||||||
title: "አቀማመጥ",
|
title: "አቀማመጥ",
|
||||||
|
|||||||
@@ -455,6 +455,7 @@ export const en = {
|
|||||||
twoStep: {
|
twoStep: {
|
||||||
title: 'Two-step verification',
|
title: 'Two-step verification',
|
||||||
desc: 'Require a one-time code from your phone each time you sign in.',
|
desc: 'Require a one-time code from your phone each time you sign in.',
|
||||||
|
saved: 'Two-step verification updated',
|
||||||
},
|
},
|
||||||
layout: {
|
layout: {
|
||||||
title: 'Layout',
|
title: 'Layout',
|
||||||
|
|||||||
@@ -129,7 +129,16 @@ export function ProfilePage() {
|
|||||||
const [isSavingPassword, setIsSavingPassword] = useState(false);
|
const [isSavingPassword, setIsSavingPassword] = useState(false);
|
||||||
const [isSavingMaritime, setIsSavingMaritime] = useState(false);
|
const [isSavingMaritime, setIsSavingMaritime] = useState(false);
|
||||||
|
|
||||||
|
// Two-step verification is wired but parked for the testing phase: turning it
|
||||||
|
// on makes every sign-in require an OTP. Swap this back for `useTwoFactor()`
|
||||||
|
// to re-enable it (the login/OTP side already handles `mfaRequired`).
|
||||||
const [twoStepEnabled, setTwoStepEnabled] = useState(false);
|
const [twoStepEnabled, setTwoStepEnabled] = useState(false);
|
||||||
|
// const {
|
||||||
|
// enabled: twoStepEnabled,
|
||||||
|
// isLoading: twoStepLoading,
|
||||||
|
// isSaving: twoStepSaving,
|
||||||
|
// setEnabled: setTwoStepEnabled,
|
||||||
|
// } = useTwoFactor();
|
||||||
const [emailNotifications, setEmailNotifications] = useState(true);
|
const [emailNotifications, setEmailNotifications] = useState(true);
|
||||||
|
|
||||||
// ---- Profession list (for Profile tab) ----
|
// ---- Profession list (for Profile tab) ----
|
||||||
@@ -855,6 +864,15 @@ export function ProfilePage() {
|
|||||||
<Switch
|
<Switch
|
||||||
checked={twoStepEnabled}
|
checked={twoStepEnabled}
|
||||||
onChange={(e) => setTwoStepEnabled(e.currentTarget.checked)}
|
onChange={(e) => setTwoStepEnabled(e.currentTarget.checked)}
|
||||||
|
// disabled={twoStepLoading || twoStepSaving}
|
||||||
|
// onChange={async (e) => {
|
||||||
|
// try {
|
||||||
|
// await setTwoStepEnabled(e.currentTarget.checked);
|
||||||
|
// notify.success(t('profile.twoStep.saved'));
|
||||||
|
// } catch (err) {
|
||||||
|
// handleError(err);
|
||||||
|
// }
|
||||||
|
// }}
|
||||||
/>
|
/>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
|
|||||||
@@ -355,6 +355,7 @@ export const am: Translations = {
|
|||||||
twoStep: {
|
twoStep: {
|
||||||
title: 'ባለ ሁለት ደረጃ ማረጋገጫ',
|
title: 'ባለ ሁለት ደረጃ ማረጋገጫ',
|
||||||
desc: 'በሚገቡበት ጊዜ ሁሉ ከስልክዎ የአንድ ጊዜ ኮድ እንዲጠየቅ ያድርጉ።',
|
desc: 'በሚገቡበት ጊዜ ሁሉ ከስልክዎ የአንድ ጊዜ ኮድ እንዲጠየቅ ያድርጉ።',
|
||||||
|
saved: 'ባለ ሁለት ደረጃ ማረጋገጫ ተዘምኗል',
|
||||||
},
|
},
|
||||||
notifications: {
|
notifications: {
|
||||||
title: 'የኢሜይል ማሳወቂያዎች',
|
title: 'የኢሜይል ማሳወቂያዎች',
|
||||||
|
|||||||
@@ -354,6 +354,7 @@ export const en = {
|
|||||||
twoStep: {
|
twoStep: {
|
||||||
title: 'Two-step verification',
|
title: 'Two-step verification',
|
||||||
desc: 'Require a one-time code from your phone each time you sign in.',
|
desc: 'Require a one-time code from your phone each time you sign in.',
|
||||||
|
saved: 'Two-step verification updated',
|
||||||
},
|
},
|
||||||
notifications: {
|
notifications: {
|
||||||
title: 'Email notifications',
|
title: 'Email notifications',
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ export {
|
|||||||
} from "./lib/store/signup.slice";
|
} from "./lib/store/signup.slice";
|
||||||
export { usePermissions } from "./lib/hooks/usePermissions";
|
export { usePermissions } from "./lib/hooks/usePermissions";
|
||||||
export { useAuthToken } from "./lib/hooks/useAuthToken";
|
export { useAuthToken } from "./lib/hooks/useAuthToken";
|
||||||
|
export { useTwoFactor } from "./lib/hooks/useTwoFactor";
|
||||||
export type { PermissionSet } from "./lib/hooks/usePermissions";
|
export type { PermissionSet } from "./lib/hooks/usePermissions";
|
||||||
export { RequirePermission } from "./lib/components/RequirePermission";
|
export { RequirePermission } from "./lib/components/RequirePermission";
|
||||||
export {
|
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",
|
method: "POST",
|
||||||
body: values,
|
body: values,
|
||||||
}).unwrap();
|
}).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));
|
dispatch(loginSuccess(data));
|
||||||
|
|
||||||
const me = await meTrigger({
|
const me = await meTrigger({
|
||||||
|
|||||||
@@ -17,10 +17,13 @@ import { Controller, useForm } from 'react-hook-form';
|
|||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { useNavigate, useLocation } from 'react-router-dom';
|
import { useNavigate, useLocation } from 'react-router-dom';
|
||||||
|
import { useDispatch } from 'react-redux';
|
||||||
import { useApiMutation } from '@ema-platform/api';
|
import { useApiMutation } from '@ema-platform/api';
|
||||||
import { notify, useErrorHandler } from '@ema-platform/ui';
|
import { notify, useErrorHandler } from '@ema-platform/ui';
|
||||||
import { AuthShell } from '../components/AuthShell';
|
import { AuthShell } from '../components/AuthShell';
|
||||||
import { useAuthConfig } from '../AuthConfig';
|
import { useAuthConfig } from '../AuthConfig';
|
||||||
|
import { loginSuccess, setUser } from '../store/auth.slice';
|
||||||
|
import type { AuthUser, LoginPayload } from '../types/auth.types';
|
||||||
|
|
||||||
const CODE_LENGTH = 6;
|
const CODE_LENGTH = 6;
|
||||||
const RESEND_SECONDS = 30;
|
const RESEND_SECONDS = 30;
|
||||||
@@ -37,14 +40,18 @@ export function OTPVerificationPage() {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const { loginRedirectPath } = useAuthConfig();
|
const { loginRedirectPath } = useAuthConfig();
|
||||||
|
const dispatch = useDispatch();
|
||||||
const state = location.state as
|
const state = location.state as
|
||||||
| { email?: string; phoneNumber?: string }
|
| { email?: string; phoneNumber?: string; mode?: 'mfa' }
|
||||||
| null;
|
| null;
|
||||||
const email = state?.email ?? '';
|
const email = state?.email ?? '';
|
||||||
const phoneNumber = state?.phoneNumber ?? '';
|
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 [resendTrigger, { isLoading: resending }] = useApiMutation();
|
||||||
|
const [meTrigger] = useApiMutation<AuthUser>();
|
||||||
const [secondsLeft, setSecondsLeft] = useState(RESEND_SECONDS);
|
const [secondsLeft, setSecondsLeft] = useState(RESEND_SECONDS);
|
||||||
const [serverError, setServerError] = useState<string | null>(null);
|
const [serverError, setServerError] = useState<string | null>(null);
|
||||||
const { handleError } = useErrorHandler();
|
const { handleError } = useErrorHandler();
|
||||||
@@ -66,6 +73,21 @@ export function OTPVerificationPage() {
|
|||||||
|
|
||||||
const onSubmit = async (values: FormValues) => {
|
const onSubmit = async (values: FormValues) => {
|
||||||
try {
|
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({
|
await verifyTrigger({
|
||||||
url: '/auth/verify-phone-number',
|
url: '/auth/verify-phone-number',
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
@@ -164,44 +186,51 @@ export function OTPVerificationPage() {
|
|||||||
</Stack>
|
</Stack>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<Divider
|
{/* Sign-in has not happened yet under MFA, so there is nothing to skip
|
||||||
label="Having trouble?"
|
to — and the resend endpoint below only regenerates phone-verification
|
||||||
labelPosition="center"
|
codes. A fresh MFA code means logging in again. */}
|
||||||
variant="dashed"
|
{!isMfa && (
|
||||||
/>
|
<>
|
||||||
|
<Divider
|
||||||
|
label="Having trouble?"
|
||||||
|
labelPosition="center"
|
||||||
|
variant="dashed"
|
||||||
|
/>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="light"
|
variant="light"
|
||||||
fullWidth
|
fullWidth
|
||||||
size="md"
|
size="md"
|
||||||
onClick={() => navigate(loginRedirectPath)}
|
onClick={() => navigate(loginRedirectPath)}
|
||||||
>
|
>
|
||||||
Skip verification for now
|
Skip verification for now
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Center>
|
<Center>
|
||||||
<Group justify="center" gap={6}>
|
<Group justify="center" gap={6}>
|
||||||
<Text size="sm" c="dimmed">
|
<Text size="sm" c="dimmed">
|
||||||
Didn't receive a code?
|
Didn't receive a code?
|
||||||
</Text>
|
</Text>
|
||||||
{secondsLeft > 0 ? (
|
{secondsLeft > 0 ? (
|
||||||
<Text size="sm" c="dimmed" fw={600}>
|
<Text size="sm" c="dimmed" fw={600}>
|
||||||
Resend in {secondsLeft}s
|
Resend in {secondsLeft}s
|
||||||
</Text>
|
</Text>
|
||||||
) : (
|
) : (
|
||||||
<Anchor
|
<Anchor
|
||||||
size="sm"
|
size="sm"
|
||||||
fw={600}
|
fw={600}
|
||||||
onClick={handleResendOtp}
|
onClick={handleResendOtp}
|
||||||
style={
|
style={
|
||||||
resending ? { pointerEvents: 'none', opacity: 0.6 } : undefined
|
resending ? { pointerEvents: 'none', opacity: 0.6 } : undefined
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
Resend code
|
Resend code
|
||||||
</Anchor>
|
</Anchor>
|
||||||
)}
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
</Center>
|
</Center>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
</AuthShell>
|
</AuthShell>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user