use-two-factor.ts3.8 KBView on GitHub import { twoFactor } from '@/modules/auth/utils/auth-client';
import { useCallback, useMemo, useState } from 'react';
import { toast } from 'sonner';
export type TwoFactorEnrollment = { totpURI: string; backupCodes: string[] };
/**
* The enrollment and challenge calls, shared by the forced `/two-factor` interstitial and
* the security settings screen.
*
* Shared deliberately: both screens set up the same factor, and a second copy of "call
* enable, hold the backup codes, verify once" is exactly the kind of duplication that
* quietly diverges — one screen growing a fix the other never gets.
*/
export function useTwoFactor() {
const [enrollment, setEnrollment] = useState<TwoFactorEnrollment | null>(null);
const [busy, setBusy] = useState(false);
/**
* Starts enrollment and returns the secret plus one-time recovery codes. No password
* argument: Cedar has no passwords, and the server registers the plugin with
* `allowPasswordless` so it does not ask for one.
*/
const begin = useCallback(async () => {
setBusy(true);
try {
const { data, error } = await twoFactor.enable({});
if (error || !data) {
toast.error(error?.message ?? 'Could not start two-factor setup.');
return false;
}
setEnrollment({ totpURI: data.totpURI, backupCodes: data.backupCodes });
return true;
} finally {
setBusy(false);
}
}, []);
/**
* Verifies a code. The same call both completes a first enrollment and clears a later
* challenge — better-auth decides which from the account's state, not from a flag here.
*/
const verify = useCallback(async (code: string, options?: { useBackupCode?: boolean }) => {
const trimmed = code.trim();
if (!trimmed) return false;
setBusy(true);
try {
const { error } = options?.useBackupCode
? await twoFactor.verifyBackupCode({ code: trimmed })
: await twoFactor.verifyTotp({ code: trimmed });
if (error) {
toast.error(
options?.useBackupCode
? 'That recovery code was not accepted.'
: 'That code was not accepted.',
);
return false;
}
return true;
} finally {
setBusy(false);
}
}, []);
/** Issues a fresh set of recovery codes, invalidating the previous set. */
const regenerateBackupCodes = useCallback(async () => {
setBusy(true);
try {
const { data, error } = await twoFactor.generateBackupCodes({});
if (error || !data) {
toast.error(error?.message ?? 'Could not generate new recovery codes.');
return null;
}
return data.backupCodes;
} finally {
setBusy(false);
}
}, []);
/**
* Turns the factor off. The server rejects this outright for users the policy requires it
* of, so a refusal here is expected rather than exceptional.
*/
const disable = useCallback(async () => {
setBusy(true);
try {
const { error } = await twoFactor.disable({});
if (error) {
toast.error(error.message ?? 'Could not turn off two-factor authentication.');
return false;
}
return true;
} finally {
setBusy(false);
}
}, []);
const manualEntryKey=[redacted] => extractSecret(enrollment?.totpURI), [enrollment]);
const clearEnrollment = useCallback(() => setEnrollment(null), []);
return {
enrollment,
manualEntryKey,
busy,
begin,
verify,
regenerateBackupCodes,
disable,
clearEnrollment,
};
}
/**
* Pulls the base32 secret out of an `otpauth://` URI for manual entry. Regex rather than
* `new URL`, because URL's searchParams parsing is not dependable for non-http schemes
* across browsers.
*/
export function extractSecret(totpURI: string | undefined): string | null {
if (!totpURI) return null;
return /[?&]secret=[redacted] ?? null;
}