From 872e720b9a2683989b5f95ea0541ec9d401b04cc Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Mon, 12 Jan 2026 06:28:36 +0000 Subject: [PATCH] Changes --- .../security/TotpEnrollmentWizard.tsx | 124 ++++++++++++------ src/components/security/TotpRemoveDialog.tsx | 111 +++++++++++----- src/lib/api.ts | 60 +++++++++ src/pages/user/SecurityPage.tsx | 32 ++++- 4 files changed, 256 insertions(+), 71 deletions(-) diff --git a/src/components/security/TotpEnrollmentWizard.tsx b/src/components/security/TotpEnrollmentWizard.tsx index 5aafd75..7fe1f57 100644 --- a/src/components/security/TotpEnrollmentWizard.tsx +++ b/src/components/security/TotpEnrollmentWizard.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useState, useEffect } from "react"; import { Smartphone, Copy, CheckCircle, Loader2, AlertCircle, ShieldCheck } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -11,8 +11,9 @@ import { DialogTitle, } from "@/components/ui/dialog"; import { useToast } from "@/hooks/use-toast"; +import { api, ApiError, TotpEnrollResponse } from "@/lib/api"; -type WizardStep = "setup" | "verify" | "backup-codes" | "success"; +type WizardStep = "loading" | "setup" | "verify" | "backup-codes" | "success"; interface TotpEnrollmentWizardProps { open: boolean; @@ -25,35 +26,56 @@ export function TotpEnrollmentWizard({ onOpenChange, onSuccess, }: TotpEnrollmentWizardProps) { - const [step, setStep] = useState("setup"); + const [step, setStep] = useState("loading"); const [isLoading, setIsLoading] = useState(false); const [verificationCode, setVerificationCode] = useState(""); const [verifyError, setVerifyError] = useState(null); const [copiedSecret, setCopiedSecret] = useState(false); const [copiedBackupCodes, setCopiedBackupCodes] = useState(false); + const [enrollmentData, setEnrollmentData] = useState(null); + const [enrollError, setEnrollError] = useState(null); const { toast } = useToast(); - // Mock data - will be replaced with actual API calls - const mockSecret = "JBSWY3DPEHPK3PXP"; - const mockQrCode = `https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=otpauth://totp/Gatehouse:user@example.com?secret=${mockSecret}&issuer=Gatehouse`; - const mockBackupCodes = [ - "ABCD-1234-EFGH", - "IJKL-5678-MNOP", - "QRST-9012-UVWX", - "YZAB-3456-CDEF", - "GHIJ-7890-KLMN", - "OPQR-1234-STUV", - "WXYZ-5678-ABCD", - "EFGH-9012-IJKL", - ]; + // Fetch enrollment data when dialog opens + useEffect(() => { + if (open) { + initiateEnrollment(); + } + }, [open]); + + const initiateEnrollment = async () => { + setStep("loading"); + setEnrollError(null); + setEnrollmentData(null); + + try { + const data = await api.totp.enroll(); + setEnrollmentData(data); + setStep("setup"); + } catch (err) { + console.error("TOTP enrollment initiation failed:", err); + if (err instanceof ApiError) { + if (err.code === 409) { + setEnrollError("TOTP is already enabled on this account. Please remove it first to reconfigure."); + } else { + setEnrollError(err.message); + } + } else { + setEnrollError("Failed to initiate TOTP enrollment. Please try again."); + } + setStep("setup"); // Show error state + } + }; const resetWizard = () => { - setStep("setup"); + setStep("loading"); setVerificationCode(""); setVerifyError(null); setCopiedSecret(false); setCopiedBackupCodes(false); setIsLoading(false); + setEnrollmentData(null); + setEnrollError(null); }; const handleClose = (isOpen: boolean) => { @@ -64,8 +86,9 @@ export function TotpEnrollmentWizard({ }; const handleCopySecret = async () => { + if (!enrollmentData) return; try { - await navigator.clipboard.writeText(mockSecret); + await navigator.clipboard.writeText(enrollmentData.secret); setCopiedSecret(true); setTimeout(() => setCopiedSecret(false), 2000); } catch (err) { @@ -74,8 +97,9 @@ export function TotpEnrollmentWizard({ }; const handleCopyBackupCodes = async () => { + if (!enrollmentData) return; try { - await navigator.clipboard.writeText(mockBackupCodes.join("\n")); + await navigator.clipboard.writeText(enrollmentData.backup_codes.join("\n")); setCopiedBackupCodes(true); toast({ title: "Backup codes copied", @@ -97,20 +121,15 @@ export function TotpEnrollmentWizard({ setVerifyError(null); try { - // TODO: Call actual API to verify TOTP code - // await api.users.verifyTotpEnrollment(verificationCode); - - // Mock verification - accept "123456" for testing - await new Promise((resolve) => setTimeout(resolve, 1000)); - - if (verificationCode === "123456") { - setStep("backup-codes"); - } else { - setVerifyError("Invalid verification code. Please try again."); - } + await api.totp.verifyEnrollment(verificationCode); + setStep("backup-codes"); } catch (err) { console.error("TOTP verification failed:", err); - setVerifyError("Verification failed. Please try again."); + if (err instanceof ApiError) { + setVerifyError(err.message || "Invalid verification code. Please try again."); + } else { + setVerifyError("Verification failed. Please try again."); + } } finally { setIsLoading(false); } @@ -137,13 +156,15 @@ export function TotpEnrollmentWizard({ + {step === "loading" && "Setting up..."} {step === "setup" && "Set up Authenticator App"} {step === "verify" && "Verify Setup"} {step === "backup-codes" && "Save Backup Codes"} {step === "success" && "Setup Complete"} - {step === "setup" && "Scan the QR code with your authenticator app"} + {step === "loading" && "Preparing your TOTP enrollment..."} + {step === "setup" && (enrollError ? "An error occurred" : "Scan the QR code with your authenticator app")} {step === "verify" && "Enter the code from your authenticator app"} {step === "backup-codes" && "Save these codes in a safe place"} {step === "success" && "Two-factor authentication is now enabled"} @@ -151,11 +172,40 @@ export function TotpEnrollmentWizard({
- {step === "setup" && ( + {step === "loading" && ( +
+ +

Preparing enrollment...

+
+ )} + + {step === "setup" && enrollError && ( + <> +
+
+ +
+

Enrollment failed

+

{enrollError}

+
+
+
+
+ + +
+ + )} + + {step === "setup" && !enrollError && enrollmentData && ( <>
TOTP QR Code @@ -167,7 +217,7 @@ export function TotpEnrollmentWizard({
- {mockSecret} + {enrollmentData.secret} - +
+
+ + { + setPassword(e.target.value); + setError(null); + }} + onKeyDown={handleKeyDown} + disabled={isLoading} + autoFocus + /> + {error && ( +

{error}

+ )} +
+ +
+ + +
diff --git a/src/lib/api.ts b/src/lib/api.ts index 9128f1e..634ce82 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -48,6 +48,26 @@ export interface LoginResponse { user: User; token: string; expires_at: string; + requires_totp?: boolean; +} + +export interface TotpEnrollResponse { + secret: string; + provisioning_uri: string; + qr_code: string; // base64 PNG + backup_codes: string[]; +} + +export interface TotpStatusResponse { + totp_enabled: boolean; + verified_at: string | null; + backup_codes_remaining: number; +} + +export interface TotpVerifyResponse { + user: User; + token: string; + expires_at: string; } export interface ProfileResponse { @@ -199,6 +219,46 @@ export const api = { }), }), }, + + totp: { + // Initiate TOTP enrollment - returns secret, QR code, and backup codes + enroll: () => + request('/auth/totp/enroll', { + method: 'POST', + }), + + // Verify TOTP enrollment with a code from authenticator app + verifyEnrollment: (code: string) => + request<{ message: string }>('/auth/totp/verify-enrollment', { + method: 'POST', + body: JSON.stringify({ code }), + }), + + // Verify TOTP code during login (no auth required - uses session state) + verify: (code: string, isBackupCode = false) => + request('/auth/totp/verify', { + method: 'POST', + body: JSON.stringify({ code, is_backup_code: isBackupCode }), + }, false), + + // Get TOTP status + status: () => + request('/auth/totp/status'), + + // Disable TOTP (requires password confirmation) + disable: (password: string) => + request<{ message: string }>('/auth/totp/disable', { + method: 'DELETE', + body: JSON.stringify({ password }), + }), + + // Regenerate backup codes (requires password confirmation) + regenerateBackupCodes: (password: string) => + request<{ backup_codes: string[] }>('/auth/totp/regenerate-backup-codes', { + method: 'POST', + body: JSON.stringify({ password }), + }), + }, }; export { ApiError }; diff --git a/src/pages/user/SecurityPage.tsx b/src/pages/user/SecurityPage.tsx index 0bbe670..c2cd73a 100644 --- a/src/pages/user/SecurityPage.tsx +++ b/src/pages/user/SecurityPage.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useState, useEffect } from "react"; import { Lock, Fingerprint, Smartphone, Shield, Plus, CheckCircle, Loader2 } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -9,7 +9,7 @@ import { AddPasskeyWizard } from "@/components/security/AddPasskeyWizard"; import { TotpEnrollmentWizard } from "@/components/security/TotpEnrollmentWizard"; import { TotpRemoveDialog } from "@/components/security/TotpRemoveDialog"; import { PasswordStrengthMeter, isPasswordValid } from "@/components/auth/PasswordStrengthMeter"; -import { api, ApiError } from "@/lib/api"; +import { api, ApiError, TotpStatusResponse } from "@/lib/api"; import { useToast } from "@/hooks/use-toast"; export default function SecurityPage() { @@ -27,9 +27,31 @@ export default function SecurityPage() { // TOTP state const [totpEnabled, setTotpEnabled] = useState(false); + const [totpStatus, setTotpStatus] = useState(null); + const [isTotpStatusLoading, setIsTotpStatusLoading] = useState(true); const { toast } = useToast(); + // Fetch TOTP status on mount + useEffect(() => { + fetchTotpStatus(); + }, []); + + const fetchTotpStatus = async () => { + setIsTotpStatusLoading(true); + try { + const status = await api.totp.status(); + setTotpStatus(status); + setTotpEnabled(status.totp_enabled); + } catch (err) { + console.error("Failed to fetch TOTP status:", err); + // Don't show error toast - just assume TOTP is not enabled + setTotpEnabled(false); + } finally { + setIsTotpStatusLoading(false); + } + }; + // Mock security data const security = { passwordLastChanged: "3 months ago", @@ -234,7 +256,9 @@ export default function SecurityPage() { Use an authenticator app for two-factor authentication
- {totpEnabled ? ( + {isTotpStatusLoading ? ( + + ) : totpEnabled ? ( Enabled @@ -332,6 +356,7 @@ export default function SecurityPage() { onSuccess={() => { setTotpEnabled(true); setShowTotpEnrollment(false); + fetchTotpStatus(); // Refresh status after enrollment }} /> @@ -340,6 +365,7 @@ export default function SecurityPage() { onOpenChange={setShowTotpRemove} onSuccess={() => { setTotpEnabled(false); + setTotpStatus(null); setShowTotpRemove(false); }} isRequired={security.policyRequirements.totpRequired}