Extract auth into hooks/useAuth; move form state into its pages
Build and Push Docker Image / build-and-push (push) Successful in 6m28s
Build and Push Docker Image / build-and-push (push) Successful in 6m28s
- useAuth owns authUser/authServerDown, the startup session check, and signIn/logout, so every sign-in path runs the same follow-up (switch local storage to the account, load reader ink). Previously reader ink only loaded on a page refresh, not right after logging in. - Login/2FA form state now lives in AuthPage, and 2FA setup, password change, and podcast name state in SettingsPage (23 fewer context values; typing in these forms no longer re-renders all of App). - Add tests for form sign-in, 2FA, session restore + logout, and the Settings podcast-name prefill. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
009fa1e78f
commit
49ffefed54
5 files changed
+162
-113
No files matched your search
+18
-83
@@ -1,6 +1,7 @@
|
|||||||
import { lazy, Suspense, useEffect, useRef, useState } from 'react';
|
import { lazy, Suspense, useEffect, useRef, useState } from 'react';
|
||||||
import { AppContext, useApp } from './context/AppContext.js';
|
import { AppContext, useApp } from './context/AppContext.js';
|
||||||
import { useAudio } from './hooks/useAudio.js';
|
import { useAudio } from './hooks/useAudio.js';
|
||||||
|
import { useAuth } from './hooks/useAuth.js';
|
||||||
import { useBibleReader } from './hooks/useBibleReader.js';
|
import { useBibleReader } from './hooks/useBibleReader.js';
|
||||||
import HomePage from './pages/HomePage.jsx';
|
import HomePage from './pages/HomePage.jsx';
|
||||||
|
|
||||||
@@ -42,8 +43,6 @@ import {
|
|||||||
deleteRemoteProject,
|
deleteRemoteProject,
|
||||||
listRemoteProjects,
|
listRemoteProjects,
|
||||||
loadRemoteProject,
|
loadRemoteProject,
|
||||||
getCurrentUser,
|
|
||||||
logoutUser,
|
|
||||||
getShareStatus,
|
getShareStatus,
|
||||||
enableSharing,
|
enableSharing,
|
||||||
disableSharing,
|
disableSharing,
|
||||||
@@ -81,30 +80,6 @@ const App = () => {
|
|||||||
title: 'Titus 1 Study',
|
title: 'Titus 1 Study',
|
||||||
});
|
});
|
||||||
const [titleEdited, setTitleEdited] = useState(false);
|
const [titleEdited, setTitleEdited] = useState(false);
|
||||||
// authUser: undefined = still checking · null = signed out · object = signed in
|
|
||||||
const [authUser, setAuthUser] = useState(undefined);
|
|
||||||
const [authServerDown, setAuthServerDown] = useState(false);
|
|
||||||
const [authMode, setAuthMode] = useState('login'); // 'login' | 'register'
|
|
||||||
const [authForm, setAuthForm] = useState({ email: '', password: '' });
|
|
||||||
const [authError, setAuthError] = useState('');
|
|
||||||
const [authBusy, setAuthBusy] = useState(false);
|
|
||||||
const [authMfaPending, setAuthMfaPending] = useState(false); // password ok, waiting on TOTP/backup code
|
|
||||||
const [authMfaCode, setAuthMfaCode] = useState('');
|
|
||||||
const [authMfaUseBackup, setAuthMfaUseBackup] = useState(false);
|
|
||||||
const [mfaSetup, setMfaSetup] = useState(null); // { secret, qrCodeDataUrl } while setup is in progress
|
|
||||||
const [mfaSetupCode, setMfaSetupCode] = useState('');
|
|
||||||
const [mfaSetupError, setMfaSetupError] = useState('');
|
|
||||||
const [mfaSetupBusy, setMfaSetupBusy] = useState(false);
|
|
||||||
const [mfaBackupCodes, setMfaBackupCodes] = useState(null); // shown once, right after enabling
|
|
||||||
const [mfaDisablePassword, setMfaDisablePassword] = useState('');
|
|
||||||
const [mfaDisableError, setMfaDisableError] = useState('');
|
|
||||||
const [podcastNameInput, setPodcastNameInput] = useState('');
|
|
||||||
const [podcastNameSaving, setPodcastNameSaving] = useState(false);
|
|
||||||
const [podcastNameSaved, setPodcastNameSaved] = useState(false);
|
|
||||||
const [changePasswordForm, setChangePasswordForm] = useState({ current: '', next: '', confirm: '' });
|
|
||||||
const [changePasswordBusy, setChangePasswordBusy] = useState(false);
|
|
||||||
const [changePasswordError, setChangePasswordError] = useState('');
|
|
||||||
const [changePasswordSaved, setChangePasswordSaved] = useState(false);
|
|
||||||
const [exportMenuOpen, setExportMenuOpen] = useState(false);
|
const [exportMenuOpen, setExportMenuOpen] = useState(false);
|
||||||
// Read-only share links
|
// Read-only share links
|
||||||
const [shareToken, setShareToken] = useState(null);
|
const [shareToken, setShareToken] = useState(null);
|
||||||
@@ -168,6 +143,20 @@ const App = () => {
|
|||||||
const [importError, setImportError] = useState('');
|
const [importError, setImportError] = useState('');
|
||||||
const reader = useBibleReader(currentPage);
|
const reader = useBibleReader(currentPage);
|
||||||
const audio = useAudio(reader.readerBookAbbrev, reader.readerChapter);
|
const audio = useAudio(reader.readerBookAbbrev, reader.readerChapter);
|
||||||
|
const { authUser, setAuthUser, authServerDown, signIn, logout } = useAuth({
|
||||||
|
onSignIn: (user) => {
|
||||||
|
setProjectIndex(switchStorageUser(user.id));
|
||||||
|
// Load reader ink from the server; server wins over any stale localStorage copy
|
||||||
|
fetch('/api/reader/ink')
|
||||||
|
.then((r) => r.ok ? r.json() : null)
|
||||||
|
.then((serverInk) => { if (serverInk) reader.mergeServerInk(serverInk); })
|
||||||
|
.catch(() => {});
|
||||||
|
},
|
||||||
|
onSignOut: () => {
|
||||||
|
setProjectIndex(switchStorageUser(null));
|
||||||
|
goHome();
|
||||||
|
},
|
||||||
|
});
|
||||||
const [studyLayout, setStudyLayout] = useState(() => {
|
const [studyLayout, setStudyLayout] = useState(() => {
|
||||||
const saved = localStorage.getItem('studyLayout');
|
const saved = localStorage.getItem('studyLayout');
|
||||||
return saved === 'stacked' ? 'stacked' : 'split'; // 'annotate' (Draw) was removed
|
return saved === 'stacked' ? 'stacked' : 'split'; // 'annotate' (Draw) was removed
|
||||||
@@ -288,34 +277,8 @@ const App = () => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
migrateOldStorageKeys();
|
migrateOldStorageKeys();
|
||||||
setProjectIndex(loadProjectIndex());
|
setProjectIndex(loadProjectIndex());
|
||||||
|
|
||||||
getCurrentUser().then((result) => {
|
|
||||||
if (result.ok) {
|
|
||||||
setAuthUser(result.user);
|
|
||||||
if (result.user) {
|
|
||||||
setProjectIndex(switchStorageUser(result.user.id));
|
|
||||||
// Load reader ink from the server; server wins over any stale localStorage copy
|
|
||||||
fetch('/api/reader/ink')
|
|
||||||
.then((r) => r.ok ? r.json() : null)
|
|
||||||
.then((serverInk) => {
|
|
||||||
if (!serverInk) return;
|
|
||||||
reader.mergeServerInk(serverInk);
|
|
||||||
})
|
|
||||||
.catch(() => {});
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Genuine network failure (server unreachable) — fall back to local-only mode.
|
|
||||||
setAuthServerDown(true);
|
|
||||||
setAuthUser(null);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Keep the Settings page's podcast-name field in sync with whichever account is signed in.
|
|
||||||
useEffect(() => {
|
|
||||||
setPodcastNameInput(authUser?.podcastName ?? '');
|
|
||||||
}, [authUser?.id]);
|
|
||||||
|
|
||||||
// A ?share=TOKEN URL loads a read-only view of someone else's project — no
|
// A ?share=TOKEN URL loads a read-only view of someone else's project — no
|
||||||
// login required, so this runs independent of auth state entirely.
|
// login required, so this runs independent of auth state entirely.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -2028,12 +1991,7 @@ const deleteProject = (id) => {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={async () => {
|
onClick={logout}
|
||||||
await logoutUser();
|
|
||||||
setAuthUser(null);
|
|
||||||
setProjectIndex(switchStorageUser(null));
|
|
||||||
goHome();
|
|
||||||
}}
|
|
||||||
className="rounded-xl border border-white/15 bg-white/10 px-3 py-1.5 text-xs text-white transition hover:bg-white/15"
|
className="rounded-xl border border-white/15 bg-white/10 px-3 py-1.5 text-xs text-white transition hover:bg-white/15"
|
||||||
>
|
>
|
||||||
Log out
|
Log out
|
||||||
@@ -2216,33 +2174,10 @@ const deleteProject = (id) => {
|
|||||||
sharedViewToken,
|
sharedViewToken,
|
||||||
sharedProject,
|
sharedProject,
|
||||||
sharedError,
|
sharedError,
|
||||||
// Auth
|
// Auth (login/2FA/settings form state lives in AuthPage and SettingsPage)
|
||||||
authUser, setAuthUser,
|
authUser, setAuthUser,
|
||||||
authStatus,
|
authStatus,
|
||||||
authMode, setAuthMode,
|
signIn,
|
||||||
authForm, setAuthForm,
|
|
||||||
authError, setAuthError,
|
|
||||||
authBusy, setAuthBusy,
|
|
||||||
authMfaPending, setAuthMfaPending,
|
|
||||||
authMfaCode, setAuthMfaCode,
|
|
||||||
authMfaUseBackup, setAuthMfaUseBackup,
|
|
||||||
setProjectIndex,
|
|
||||||
// Settings — MFA
|
|
||||||
mfaSetup, setMfaSetup,
|
|
||||||
mfaSetupCode, setMfaSetupCode,
|
|
||||||
mfaSetupError, setMfaSetupError,
|
|
||||||
mfaSetupBusy, setMfaSetupBusy,
|
|
||||||
mfaBackupCodes, setMfaBackupCodes,
|
|
||||||
mfaDisablePassword, setMfaDisablePassword,
|
|
||||||
mfaDisableError, setMfaDisableError,
|
|
||||||
// Settings — password / podcast
|
|
||||||
podcastNameInput, setPodcastNameInput,
|
|
||||||
podcastNameSaving, setPodcastNameSaving,
|
|
||||||
podcastNameSaved, setPodcastNameSaved,
|
|
||||||
changePasswordForm, setChangePasswordForm,
|
|
||||||
changePasswordBusy, setChangePasswordBusy,
|
|
||||||
changePasswordError, setChangePasswordError,
|
|
||||||
changePasswordSaved, setChangePasswordSaved,
|
|
||||||
// Home
|
// Home
|
||||||
projectIndex,
|
projectIndex,
|
||||||
autoRestoredCount,
|
autoRestoredCount,
|
||||||
|
|||||||
@@ -363,3 +363,77 @@ describe('Greek word lookup', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Authentication (useAuth + AuthPage/SettingsPage local form state)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
describe('Authentication', () => {
|
||||||
|
const user = { id: 'u1', email: 'reader@example.com', totpEnabled: false, podcastName: 'My Show', isAdmin: false };
|
||||||
|
const json = (status, data) => Promise.resolve({ ok: status < 400, status, json: () => Promise.resolve(data) });
|
||||||
|
|
||||||
|
// A tiny fake API: `session` decides what /api/auth/me returns; login can
|
||||||
|
// optionally require a 2FA code first.
|
||||||
|
function stubApi({ session = null, mfaRequired = false } = {}) {
|
||||||
|
const fallback = buildFetchMock();
|
||||||
|
const fetchMock = vi.fn((url, opts = {}) => {
|
||||||
|
if (!url.startsWith('/api/')) return fallback(url, opts);
|
||||||
|
const route = `${opts.method ?? 'GET'} ${url}`;
|
||||||
|
if (route === 'GET /api/auth/me') return session ? json(200, session) : json(401, { error: 'Not signed in' });
|
||||||
|
if (route === 'POST /api/auth/login') return json(200, mfaRequired ? { mfaRequired: true } : user);
|
||||||
|
if (route === 'POST /api/auth/mfa/verify') return json(200, user);
|
||||||
|
if (route === 'POST /api/auth/logout') return json(200, { ok: true });
|
||||||
|
if (route === 'GET /api/projects') return json(200, []);
|
||||||
|
if (route === 'GET /api/reader/ink') return json(200, {});
|
||||||
|
return json(404, { error: 'not mocked' });
|
||||||
|
});
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
return fetchMock;
|
||||||
|
}
|
||||||
|
const calledWith = (fetchMock, method, url) =>
|
||||||
|
fetchMock.mock.calls.some(([u, o = {}]) => u === url && (o.method ?? 'GET') === method);
|
||||||
|
|
||||||
|
async function signInWithForm(u) {
|
||||||
|
await u.type(await screen.findByLabelText(/email/i), user.email);
|
||||||
|
await u.type(screen.getByLabelText(/password/i), 'correct-horse');
|
||||||
|
await u.click(screen.getByRole('button', { name: /^sign in$/i }));
|
||||||
|
}
|
||||||
|
|
||||||
|
test('signing in with the form shows the app and loads reader ink', async () => {
|
||||||
|
const fetchMock = stubApi();
|
||||||
|
const u = userEvent.setup();
|
||||||
|
render(<App />);
|
||||||
|
await signInWithForm(u);
|
||||||
|
expect(await screen.findByText(user.email)).toBeInTheDocument();
|
||||||
|
expect(calledWith(fetchMock, 'POST', '/api/auth/login')).toBe(true);
|
||||||
|
await waitFor(() => expect(calledWith(fetchMock, 'GET', '/api/reader/ink')).toBe(true));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('2FA: a correct code after the password signs the user in', async () => {
|
||||||
|
const fetchMock = stubApi({ mfaRequired: true });
|
||||||
|
const u = userEvent.setup();
|
||||||
|
render(<App />);
|
||||||
|
await signInWithForm(u);
|
||||||
|
await u.type(await screen.findByPlaceholderText('123456'), '123456');
|
||||||
|
await u.click(screen.getByRole('button', { name: /verify/i }));
|
||||||
|
expect(await screen.findByText(user.email)).toBeInTheDocument();
|
||||||
|
expect(calledWith(fetchMock, 'POST', '/api/auth/mfa/verify')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an existing session skips the login screen, and Log out returns to it', async () => {
|
||||||
|
const fetchMock = stubApi({ session: user });
|
||||||
|
const u = userEvent.setup();
|
||||||
|
render(<App />);
|
||||||
|
expect(await screen.findByText(user.email)).toBeInTheDocument();
|
||||||
|
await u.click(screen.getByRole('button', { name: /log out/i }));
|
||||||
|
expect(await screen.findByRole('button', { name: /^sign in$/i })).toBeInTheDocument();
|
||||||
|
expect(calledWith(fetchMock, 'POST', '/api/auth/logout')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Settings pre-fills the podcast name from the signed-in account', async () => {
|
||||||
|
stubApi({ session: user });
|
||||||
|
const u = userEvent.setup();
|
||||||
|
render(<App />);
|
||||||
|
await u.click(await screen.findByRole('button', { name: /settings/i }));
|
||||||
|
expect(await screen.findByDisplayValue('My Show')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
// Who is signed in. Checks the session on startup and exposes signIn/logout so
|
||||||
|
// every sign-in path (startup, login form, 2FA) runs the same follow-up work.
|
||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { getCurrentUser, logoutUser } from '../syncService.js';
|
||||||
|
|
||||||
|
// onSignIn(user) runs right after a user becomes signed in (switch local
|
||||||
|
// storage to their account, load their data). onSignOut() runs after logout.
|
||||||
|
export function useAuth({ onSignIn, onSignOut }) {
|
||||||
|
// authUser: undefined = still checking · null = signed out · object = signed in
|
||||||
|
const [authUser, setAuthUser] = useState(undefined);
|
||||||
|
const [authServerDown, setAuthServerDown] = useState(false);
|
||||||
|
|
||||||
|
// Callbacks come from App and close over its latest state; read them through
|
||||||
|
// a ref so signIn/logout never call a stale copy.
|
||||||
|
const callbacksRef = useRef({ onSignIn, onSignOut });
|
||||||
|
callbacksRef.current = { onSignIn, onSignOut };
|
||||||
|
|
||||||
|
const signIn = (user) => {
|
||||||
|
setAuthUser(user);
|
||||||
|
callbacksRef.current.onSignIn(user);
|
||||||
|
};
|
||||||
|
|
||||||
|
const logout = async () => {
|
||||||
|
await logoutUser();
|
||||||
|
setAuthUser(null);
|
||||||
|
callbacksRef.current.onSignOut();
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getCurrentUser().then((result) => {
|
||||||
|
if (result.ok) {
|
||||||
|
if (result.user) signIn(result.user);
|
||||||
|
else setAuthUser(null);
|
||||||
|
} else {
|
||||||
|
// Genuine network failure (server unreachable) — fall back to local-only mode.
|
||||||
|
setAuthServerDown(true);
|
||||||
|
setAuthUser(null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { authUser, setAuthUser, authServerDown, signIn, logout };
|
||||||
|
}
|
||||||
+11
-16
@@ -1,19 +1,16 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
import { useApp } from '../context/AppContext.js';
|
import { useApp } from '../context/AppContext.js';
|
||||||
import { loginUser, registerUser, verifyMfaLogin, logoutUser } from '../syncService.js';
|
import { loginUser, registerUser, verifyMfaLogin, logoutUser } from '../syncService.js';
|
||||||
import { switchStorageUser } from '../utils/bibleUtils.jsx';
|
|
||||||
|
|
||||||
export default function AuthPage() {
|
export default function AuthPage() {
|
||||||
const {
|
const { signIn } = useApp();
|
||||||
authMode, setAuthMode,
|
const [authMode, setAuthMode] = useState('login'); // 'login' | 'register'
|
||||||
authForm, setAuthForm,
|
const [authForm, setAuthForm] = useState({ email: '', password: '' });
|
||||||
authError, setAuthError,
|
const [authError, setAuthError] = useState('');
|
||||||
authBusy, setAuthBusy,
|
const [authBusy, setAuthBusy] = useState(false);
|
||||||
authMfaPending, setAuthMfaPending,
|
const [authMfaPending, setAuthMfaPending] = useState(false); // password ok, waiting on TOTP/backup code
|
||||||
authMfaCode, setAuthMfaCode,
|
const [authMfaCode, setAuthMfaCode] = useState('');
|
||||||
authMfaUseBackup, setAuthMfaUseBackup,
|
const [authMfaUseBackup, setAuthMfaUseBackup] = useState(false);
|
||||||
setAuthUser,
|
|
||||||
setProjectIndex,
|
|
||||||
} = useApp();
|
|
||||||
|
|
||||||
const submitAuth = async (e) => {
|
const submitAuth = async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -30,8 +27,7 @@ export default function AuthPage() {
|
|||||||
setAuthMfaPending(true);
|
setAuthMfaPending(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setAuthUser(result.data);
|
signIn(result.data);
|
||||||
setProjectIndex(switchStorageUser(result.data.id));
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const submitMfa = async (e) => {
|
const submitMfa = async (e) => {
|
||||||
@@ -48,8 +44,7 @@ export default function AuthPage() {
|
|||||||
}
|
}
|
||||||
setAuthMfaPending(false);
|
setAuthMfaPending(false);
|
||||||
setAuthMfaCode('');
|
setAuthMfaCode('');
|
||||||
setAuthUser(result.data);
|
signIn(result.data);
|
||||||
setProjectIndex(switchStorageUser(result.data.id));
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (authMfaPending) {
|
if (authMfaPending) {
|
||||||
|
|||||||
+15
-14
@@ -1,3 +1,4 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
import { useApp } from '../context/AppContext.js';
|
import { useApp } from '../context/AppContext.js';
|
||||||
import {
|
import {
|
||||||
startMfaSetup,
|
startMfaSetup,
|
||||||
@@ -12,21 +13,21 @@ export default function SettingsPage() {
|
|||||||
authUser, setAuthUser,
|
authUser, setAuthUser,
|
||||||
authStatus,
|
authStatus,
|
||||||
goHome,
|
goHome,
|
||||||
mfaSetup, setMfaSetup,
|
|
||||||
mfaSetupCode, setMfaSetupCode,
|
|
||||||
mfaSetupError, setMfaSetupError,
|
|
||||||
mfaSetupBusy, setMfaSetupBusy,
|
|
||||||
mfaBackupCodes, setMfaBackupCodes,
|
|
||||||
mfaDisablePassword, setMfaDisablePassword,
|
|
||||||
mfaDisableError, setMfaDisableError,
|
|
||||||
podcastNameInput, setPodcastNameInput,
|
|
||||||
podcastNameSaving, setPodcastNameSaving,
|
|
||||||
podcastNameSaved, setPodcastNameSaved,
|
|
||||||
changePasswordForm, setChangePasswordForm,
|
|
||||||
changePasswordBusy, setChangePasswordBusy,
|
|
||||||
changePasswordError, setChangePasswordError,
|
|
||||||
changePasswordSaved, setChangePasswordSaved,
|
|
||||||
} = useApp();
|
} = useApp();
|
||||||
|
const [mfaSetup, setMfaSetup] = useState(null); // { secret, qrCodeDataUrl } while setup is in progress
|
||||||
|
const [mfaSetupCode, setMfaSetupCode] = useState('');
|
||||||
|
const [mfaSetupError, setMfaSetupError] = useState('');
|
||||||
|
const [mfaSetupBusy, setMfaSetupBusy] = useState(false);
|
||||||
|
const [mfaBackupCodes, setMfaBackupCodes] = useState(null); // shown once, right after enabling
|
||||||
|
const [mfaDisablePassword, setMfaDisablePassword] = useState('');
|
||||||
|
const [mfaDisableError, setMfaDisableError] = useState('');
|
||||||
|
const [podcastNameInput, setPodcastNameInput] = useState(authUser?.podcastName ?? '');
|
||||||
|
const [podcastNameSaving, setPodcastNameSaving] = useState(false);
|
||||||
|
const [podcastNameSaved, setPodcastNameSaved] = useState(false);
|
||||||
|
const [changePasswordForm, setChangePasswordForm] = useState({ current: '', next: '', confirm: '' });
|
||||||
|
const [changePasswordBusy, setChangePasswordBusy] = useState(false);
|
||||||
|
const [changePasswordError, setChangePasswordError] = useState('');
|
||||||
|
const [changePasswordSaved, setChangePasswordSaved] = useState(false);
|
||||||
|
|
||||||
const startSetup = async () => {
|
const startSetup = async () => {
|
||||||
setMfaSetupError('');
|
setMfaSetupError('');
|
||||||
|
|||||||
Reference in new issue
Block a user