Skip to content

Commit 2be77c1

Browse files
committed
feat(ui-react): add two-factor authentication (TOTP) support
Implements TOTP-based MFA with authenticator app support: - MFA login page with 6-digit code input and paste support - MFA recovery flow using backup codes - Profile page integration for MFA setup (QR code, recovery codes) - Auth flow updates to handle MFA token and programmatic navigation - API interceptor support for MFA challenges (401 with x-mfa-token) Dependencies: - qrcode.react for QR code generation - otpauth for TOTP secret URI formatting
1 parent 74ec2d1 commit 2be77c1

File tree

18 files changed

+2138
-11
lines changed

18 files changed

+2138
-11
lines changed

ui-react/apps/admin/src/App.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@ import {
88
import Login from "./pages/Login";
99
import Setup from "./pages/Setup";
1010
import AppLayout from "./components/layout/AppLayout";
11+
12+
const MfaLogin = lazy(() => import("./pages/MfaLogin"));
13+
const MfaRecover = lazy(() => import("./pages/MfaRecover"));
1114
import LoginLayout from "./components/layout/LoginLayout";
1215
import ConnectivityGuard from "./components/common/ConnectivityGuard";
1316
import ProtectedRoute from "./components/common/ProtectedRoute";
@@ -38,6 +41,8 @@ export default function App() {
3841
<Route element={<SetupGuard />}>
3942
<Route element={<LoginLayout />}>
4043
<Route path="/login" element={<Login />} />
44+
<Route path="/mfa-login" element={<MfaLogin />} />
45+
<Route path="/mfa-recover" element={<MfaRecover />} />
4146
<Route path="/setup" element={<Setup />} />
4247
</Route>
4348
<Route element={<ProtectedRoute />}>

ui-react/apps/admin/src/api/auth.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ interface UserResponse {
2121
email: string;
2222
recovery_email: string;
2323
tenant: string;
24+
mfa?: boolean;
2425
}
2526

2627
export async function login(payload: LoginPayload): Promise<LoginResponse> {

ui-react/apps/admin/src/api/interceptors.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,16 @@ export function setupInterceptors(instance: AxiosInstance) {
6262
},
6363
(error: AxiosError) => {
6464
if (error.response?.status === 401) {
65+
// Check for MFA token in response headers
66+
const mfaToken = error.response.headers["x-mfa-token"];
67+
68+
if (mfaToken) {
69+
// MFA required - store token, let Login component handle navigation
70+
useAuthStore.getState().setMfaToken(mfaToken);
71+
return Promise.reject(error);
72+
}
73+
74+
// Regular 401 - logout
6575
useAuthStore.getState().logout();
6676
window.location.href = "/v2/ui/login";
6777
} else if (isApiDown(error)) {

ui-react/apps/admin/src/api/mfa.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import apiClient from "./client";
2+
import type {
3+
MfaGenerateResponse,
4+
MfaEnableRequest,
5+
MfaAuthRequest,
6+
MfaDisableRequest,
7+
MfaRecoverRequest,
8+
MfaResetRequest,
9+
LoginResponse,
10+
} from "../types/mfa";
11+
12+
// Generate QR code and recovery codes
13+
export async function generateMfa(): Promise<MfaGenerateResponse> {
14+
const { data } = await apiClient.get<MfaGenerateResponse>(
15+
"/api/user/mfa/generate"
16+
);
17+
return data;
18+
}
19+
20+
// Enable MFA with verification
21+
export async function enableMfa(payload: MfaEnableRequest): Promise<void> {
22+
await apiClient.put("/api/user/mfa/enable", payload);
23+
}
24+
25+
// Validate MFA code after password login
26+
export async function validateMfa(
27+
payload: MfaAuthRequest
28+
): Promise<LoginResponse> {
29+
const { data } = await apiClient.post<LoginResponse>(
30+
"/api/user/mfa/auth",
31+
payload
32+
);
33+
return data;
34+
}
35+
36+
// Disable MFA
37+
export async function disableMfa(payload: MfaDisableRequest): Promise<void> {
38+
await apiClient.put("/api/user/mfa/disable", payload);
39+
}
40+
41+
// Recover account with recovery code
42+
export async function recoverMfa(
43+
payload: MfaRecoverRequest
44+
): Promise<{ data: LoginResponse; expiresAt: string }> {
45+
const response = await apiClient.post<LoginResponse>(
46+
"/api/user/mfa/recover",
47+
payload
48+
);
49+
return {
50+
data: response.data,
51+
expiresAt: response.headers["x-expires-at"] || "",
52+
};
53+
}
54+
55+
// Request MFA reset via email
56+
export async function requestMfaReset(identifier: string): Promise<void> {
57+
await apiClient.post("/api/user/mfa/reset", { identifier });
58+
}
59+
60+
// Complete MFA reset with email codes
61+
export async function completeMfaReset(
62+
userId: string,
63+
payload: MfaResetRequest
64+
): Promise<LoginResponse> {
65+
const { data } = await apiClient.put<LoginResponse>(
66+
`/api/user/mfa/reset/${userId}`,
67+
payload
68+
);
69+
return data;
70+
}
Lines changed: 226 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,226 @@
1+
import { useState, FormEvent, useRef, KeyboardEvent } from "react";
2+
import { ExclamationTriangleIcon } from "@heroicons/react/24/outline";
3+
import { disableMfa } from "../../api/mfa";
4+
5+
interface MfaDisableDialogProps {
6+
open: boolean;
7+
onClose: () => void;
8+
onSuccess: () => void;
9+
}
10+
11+
type Mode = "totp" | "recovery";
12+
13+
export default function MfaDisableDialog({
14+
open,
15+
onClose,
16+
onSuccess,
17+
}: MfaDisableDialogProps) {
18+
const [mode, setMode] = useState<Mode>("totp");
19+
const [code, setCode] = useState(["", "", "", "", "", ""]);
20+
const [recoveryCode, setRecoveryCode] = useState("");
21+
const [submitting, setSubmitting] = useState(false);
22+
const [error, setError] = useState("");
23+
const inputRefs = useRef<(HTMLInputElement | null)[]>([]);
24+
25+
if (!open) return null;
26+
27+
const handleCodeChange = (index: number, value: string) => {
28+
if (value && !/^\d$/.test(value)) return;
29+
30+
const newCode = [...code];
31+
newCode[index] = value;
32+
setCode(newCode);
33+
34+
if (value && index < 5) {
35+
inputRefs.current[index + 1]?.focus();
36+
}
37+
};
38+
39+
const handleCodeKeyDown = (index: number, e: KeyboardEvent<HTMLInputElement>) => {
40+
if (e.key === "Backspace") {
41+
if (!code[index] && index > 0) {
42+
const newCode = [...code];
43+
newCode[index - 1] = "";
44+
setCode(newCode);
45+
inputRefs.current[index - 1]?.focus();
46+
} else {
47+
const newCode = [...code];
48+
newCode[index] = "";
49+
setCode(newCode);
50+
}
51+
}
52+
};
53+
54+
const handleSubmit = async (e: FormEvent) => {
55+
e.preventDefault();
56+
setError("");
57+
setSubmitting(true);
58+
59+
try {
60+
if (mode === "totp") {
61+
const totpCode = code.join("");
62+
if (totpCode.length !== 6) return;
63+
await disableMfa({ code: totpCode });
64+
} else {
65+
if (!recoveryCode.trim()) return;
66+
await disableMfa({ recovery_code: recoveryCode });
67+
}
68+
69+
onSuccess();
70+
onClose();
71+
// Reset state
72+
setTimeout(() => {
73+
setMode("totp");
74+
setCode(["", "", "", "", "", ""]);
75+
setRecoveryCode("");
76+
setError("");
77+
}, 300);
78+
} catch {
79+
setError(
80+
mode === "totp"
81+
? "Invalid verification code"
82+
: "Invalid recovery code"
83+
);
84+
if (mode === "totp") {
85+
setCode(["", "", "", "", "", ""]);
86+
inputRefs.current[0]?.focus();
87+
}
88+
} finally {
89+
setSubmitting(false);
90+
}
91+
};
92+
93+
const isComplete =
94+
mode === "totp"
95+
? code.every((digit) => digit !== "")
96+
: recoveryCode.trim() !== "";
97+
98+
return (
99+
<div className="fixed inset-0 z-[70] flex items-center justify-center">
100+
<div
101+
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
102+
onClick={onClose}
103+
/>
104+
<div className="relative bg-surface border border-border rounded-2xl w-full max-w-sm mx-4 p-6 shadow-2xl animate-slide-up">
105+
{/* Header */}
106+
<div className="flex items-start gap-3 mb-4">
107+
<div className="flex-shrink-0 w-10 h-10 rounded-lg bg-accent-red/15 border border-accent-red/25 flex items-center justify-center">
108+
<ExclamationTriangleIcon
109+
className="w-5 h-5 text-accent-red"
110+
strokeWidth={2}
111+
/>
112+
</div>
113+
<div>
114+
<h2 className="text-base font-semibold text-text-primary">
115+
Disable MFA
116+
</h2>
117+
<p className="text-xs text-text-muted mt-0.5">
118+
This will reduce your account security
119+
</p>
120+
</div>
121+
</div>
122+
123+
<form onSubmit={handleSubmit} className="space-y-4">
124+
{error && (
125+
<div className="flex items-center gap-2 bg-accent-red/8 border border-accent-red/20 text-accent-red px-3.5 py-2.5 rounded-md text-xs font-mono">
126+
<ExclamationTriangleIcon
127+
className="w-3.5 h-3.5 shrink-0"
128+
strokeWidth={2}
129+
/>
130+
{error}
131+
</div>
132+
)}
133+
134+
{mode === "totp" ? (
135+
<>
136+
<div>
137+
<label className="block text-2xs font-mono font-semibold uppercase tracking-label text-text-muted mb-3 text-center">
138+
Verification Code
139+
</label>
140+
<div className="flex gap-2 justify-center">
141+
{code.map((digit, index) => (
142+
<input
143+
key={index}
144+
ref={(el) => (inputRefs.current[index] = el)}
145+
type="text"
146+
inputMode="numeric"
147+
maxLength={1}
148+
value={digit}
149+
onChange={(e) => handleCodeChange(index, e.target.value)}
150+
onKeyDown={(e) => handleCodeKeyDown(index, e)}
151+
autoFocus={index === 0}
152+
className="w-10 h-10 text-center text-base font-mono bg-background border border-border rounded-lg text-text-primary focus:outline-none focus:border-accent-red/50 focus:ring-1 focus:ring-accent-red/20 transition-all"
153+
/>
154+
))}
155+
</div>
156+
</div>
157+
158+
<div className="text-center">
159+
<button
160+
type="button"
161+
onClick={() => setMode("recovery")}
162+
className="text-xs text-text-muted hover:text-text-secondary transition-colors"
163+
>
164+
Use recovery code instead
165+
</button>
166+
</div>
167+
</>
168+
) : (
169+
<>
170+
<div>
171+
<label className="block text-2xs font-mono font-semibold uppercase tracking-label text-text-muted mb-2">
172+
Recovery Code
173+
</label>
174+
<input
175+
type="text"
176+
value={recoveryCode}
177+
onChange={(e) => setRecoveryCode(e.target.value)}
178+
autoFocus
179+
className="w-full px-4 py-2.5 bg-background border border-border rounded-lg text-sm text-text-primary font-mono placeholder:text-text-secondary focus:outline-none focus:border-accent-red/50 focus:ring-1 focus:ring-accent-red/20 transition-all"
180+
placeholder="Enter recovery code"
181+
/>
182+
</div>
183+
184+
<div className="text-center space-y-2">
185+
<button
186+
type="button"
187+
onClick={() => setMode("totp")}
188+
className="block w-full text-xs text-text-muted hover:text-text-secondary transition-colors"
189+
>
190+
← Use authenticator code
191+
</button>
192+
<a
193+
href="mailto:support@shellhub.io"
194+
className="block text-xs text-text-muted hover:text-text-secondary transition-colors"
195+
>
196+
Request email reset
197+
</a>
198+
</div>
199+
</>
200+
)}
201+
202+
{/* Actions */}
203+
<div className="flex justify-end gap-2 pt-2">
204+
<button
205+
type="button"
206+
onClick={onClose}
207+
className="px-4 py-2.5 text-sm font-medium text-text-secondary hover:text-text-primary rounded-lg hover:bg-hover-subtle transition-colors"
208+
>
209+
Cancel
210+
</button>
211+
<button
212+
type="submit"
213+
disabled={submitting || !isComplete}
214+
className="px-5 py-2.5 bg-accent-red/90 hover:bg-accent-red text-white rounded-lg text-sm font-semibold disabled:opacity-dim disabled:cursor-not-allowed transition-all flex items-center gap-2"
215+
>
216+
{submitting && (
217+
<span className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
218+
)}
219+
Disable MFA
220+
</button>
221+
</div>
222+
</form>
223+
</div>
224+
</div>
225+
);
226+
}

0 commit comments

Comments
 (0)