From ff89b9af5e8912440db6d87ad648b872d34b9316 Mon Sep 17 00:00:00 2001 From: estifanos Date: Wed, 19 Aug 2026 10:42:30 +0000 Subject: [PATCH] feat: implement two-factor authentication flow during sign-in and add utility hooks for configuration management --- apps/backoffice/src/app/i18n/locales/am.ts | 1 + apps/backoffice/src/app/i18n/locales/en.ts | 1 + .../features/profile/pages/ProfilePage.tsx | 18 +++ apps/portal/src/app/i18n/locales/am.ts | 1 + apps/portal/src/app/i18n/locales/en.ts | 1 + libs/auth/src/index.ts | 1 + .../src/lib/hooks/two-factor-request.spec.ts | 22 ++++ libs/auth/src/lib/hooks/two-factor-request.ts | 26 +++++ libs/auth/src/lib/hooks/useTwoFactor.ts | 21 ++++ libs/auth/src/lib/pages/LoginPage.tsx | 11 ++ .../src/lib/pages/OTPVerificationPage.tsx | 105 +++++++++++------- 11 files changed, 170 insertions(+), 38 deletions(-) create mode 100644 libs/auth/src/lib/hooks/two-factor-request.spec.ts create mode 100644 libs/auth/src/lib/hooks/two-factor-request.ts create mode 100644 libs/auth/src/lib/hooks/useTwoFactor.ts diff --git a/apps/backoffice/src/app/i18n/locales/am.ts b/apps/backoffice/src/app/i18n/locales/am.ts index 1393f0704..0d0f0dc58 100644 --- a/apps/backoffice/src/app/i18n/locales/am.ts +++ b/apps/backoffice/src/app/i18n/locales/am.ts @@ -457,6 +457,7 @@ export const am: Translations = { twoStep: { title: "ባለሁለት ደረጃ ማረጋገጫ", desc: "በየጊዜው ሲገቡ ከስልክዎ የአንድ ጊዜ ኮድ ያስፈልጋል።", + saved: "ባለሁለት ደረጃ ማረጋገጫ ተዘምኗል", }, layout: { title: "አቀማመጥ", diff --git a/apps/backoffice/src/app/i18n/locales/en.ts b/apps/backoffice/src/app/i18n/locales/en.ts index b628776dc..6b151ea0f 100644 --- a/apps/backoffice/src/app/i18n/locales/en.ts +++ b/apps/backoffice/src/app/i18n/locales/en.ts @@ -455,6 +455,7 @@ export const en = { twoStep: { title: 'Two-step verification', desc: 'Require a one-time code from your phone each time you sign in.', + saved: 'Two-step verification updated', }, layout: { title: 'Layout', diff --git a/apps/portal/src/app/features/profile/pages/ProfilePage.tsx b/apps/portal/src/app/features/profile/pages/ProfilePage.tsx index 73d9a0f34..054bd5420 100644 --- a/apps/portal/src/app/features/profile/pages/ProfilePage.tsx +++ b/apps/portal/src/app/features/profile/pages/ProfilePage.tsx @@ -129,7 +129,16 @@ export function ProfilePage() { const [isSavingPassword, setIsSavingPassword] = 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 { + // enabled: twoStepEnabled, + // isLoading: twoStepLoading, + // isSaving: twoStepSaving, + // setEnabled: setTwoStepEnabled, + // } = useTwoFactor(); const [emailNotifications, setEmailNotifications] = useState(true); // ---- Profession list (for Profile tab) ---- @@ -855,6 +864,15 @@ export function ProfilePage() { 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); + // } + // }} /> diff --git a/apps/portal/src/app/i18n/locales/am.ts b/apps/portal/src/app/i18n/locales/am.ts index 42260e70a..4f62650c6 100644 --- a/apps/portal/src/app/i18n/locales/am.ts +++ b/apps/portal/src/app/i18n/locales/am.ts @@ -355,6 +355,7 @@ export const am: Translations = { twoStep: { title: 'ባለ ሁለት ደረጃ ማረጋገጫ', desc: 'በሚገቡበት ጊዜ ሁሉ ከስልክዎ የአንድ ጊዜ ኮድ እንዲጠየቅ ያድርጉ።', + saved: 'ባለ ሁለት ደረጃ ማረጋገጫ ተዘምኗል', }, notifications: { title: 'የኢሜይል ማሳወቂያዎች', diff --git a/apps/portal/src/app/i18n/locales/en.ts b/apps/portal/src/app/i18n/locales/en.ts index 5a94fbd63..6f5dd3f50 100644 --- a/apps/portal/src/app/i18n/locales/en.ts +++ b/apps/portal/src/app/i18n/locales/en.ts @@ -354,6 +354,7 @@ export const en = { twoStep: { title: 'Two-step verification', desc: 'Require a one-time code from your phone each time you sign in.', + saved: 'Two-step verification updated', }, notifications: { title: 'Email notifications', diff --git a/libs/auth/src/index.ts b/libs/auth/src/index.ts index b1d90b33a..5e99e4520 100644 --- a/libs/auth/src/index.ts +++ b/libs/auth/src/index.ts @@ -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 { diff --git a/libs/auth/src/lib/hooks/two-factor-request.spec.ts b/libs/auth/src/lib/hooks/two-factor-request.spec.ts new file mode 100644 index 000000000..3d5f4e987 --- /dev/null +++ b/libs/auth/src/lib/hooks/two-factor-request.spec.ts @@ -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 }, + }); + }); +}); diff --git a/libs/auth/src/lib/hooks/two-factor-request.ts b/libs/auth/src/lib/hooks/two-factor-request.ts new file mode 100644 index 000000000..a8c6f1106 --- /dev/null +++ b/libs/auth/src/lib/hooks/two-factor-request.ts @@ -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 }, + }; +} diff --git a/libs/auth/src/lib/hooks/useTwoFactor.ts b/libs/auth/src/lib/hooks/useTwoFactor.ts new file mode 100644 index 000000000..7dfb9d367 --- /dev/null +++ b/libs/auth/src/lib/hooks/useTwoFactor.ts @@ -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 }; +} diff --git a/libs/auth/src/lib/pages/LoginPage.tsx b/libs/auth/src/lib/pages/LoginPage.tsx index 63e4fe803..16072b4e4 100644 --- a/libs/auth/src/lib/pages/LoginPage.tsx +++ b/libs/auth/src/lib/pages/LoginPage.tsx @@ -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({ diff --git a/libs/auth/src/lib/pages/OTPVerificationPage.tsx b/libs/auth/src/lib/pages/OTPVerificationPage.tsx index 9d0f94bb3..17ec4e659 100644 --- a/libs/auth/src/lib/pages/OTPVerificationPage.tsx +++ b/libs/auth/src/lib/pages/OTPVerificationPage.tsx @@ -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(); const [resendTrigger, { isLoading: resending }] = useApiMutation(); + const [meTrigger] = useApiMutation(); const [secondsLeft, setSecondsLeft] = useState(RESEND_SECONDS); const [serverError, setServerError] = useState(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() { - + {/* 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 && ( + <> + - + -
- - - Didn't receive a code? - - {secondsLeft > 0 ? ( - - Resend in {secondsLeft}s - - ) : ( - - Resend code - - )} - -
+
+ + + Didn't receive a code? + + {secondsLeft > 0 ? ( + + Resend in {secondsLeft}s + + ) : ( + + Resend code + + )} + +
+ + )} );