Enhance totp UI flow

- Add TOTP enrollment UI flow to SecurityPage via TotpEnrollmentWizard
- Integrate removal dialog TotpRemoveDialog with confirmation
- Update SecurityPage to reference new TOTP components and reflect enrollment state
- Implement wizard steps: setup, verify, backup-codes, and completion
- Show enabling status and removal option with confirmation when enrolled

X-Lovable-Edit-ID: edt-8f92b58a-f7e2-4820-9941-aeb31a19c58f
This commit is contained in:
gpt-engineer-app[bot]
2026-01-11 09:46:53 +00:00
3 changed files with 462 additions and 7 deletions
@@ -0,0 +1,310 @@
import { useState } from "react";
import { Smartphone, Copy, CheckCircle, Loader2, AlertCircle, ShieldCheck } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { useToast } from "@/hooks/use-toast";
type WizardStep = "setup" | "verify" | "backup-codes" | "success";
interface TotpEnrollmentWizardProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onSuccess: () => void;
}
export function TotpEnrollmentWizard({
open,
onOpenChange,
onSuccess,
}: TotpEnrollmentWizardProps) {
const [step, setStep] = useState<WizardStep>("setup");
const [isLoading, setIsLoading] = useState(false);
const [verificationCode, setVerificationCode] = useState("");
const [verifyError, setVerifyError] = useState<string | null>(null);
const [copiedSecret, setCopiedSecret] = useState(false);
const [copiedBackupCodes, setCopiedBackupCodes] = useState(false);
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",
];
const resetWizard = () => {
setStep("setup");
setVerificationCode("");
setVerifyError(null);
setCopiedSecret(false);
setCopiedBackupCodes(false);
setIsLoading(false);
};
const handleClose = (isOpen: boolean) => {
if (!isOpen) {
resetWizard();
}
onOpenChange(isOpen);
};
const handleCopySecret = async () => {
try {
await navigator.clipboard.writeText(mockSecret);
setCopiedSecret(true);
setTimeout(() => setCopiedSecret(false), 2000);
} catch (err) {
console.error("Failed to copy secret:", err);
}
};
const handleCopyBackupCodes = async () => {
try {
await navigator.clipboard.writeText(mockBackupCodes.join("\n"));
setCopiedBackupCodes(true);
toast({
title: "Backup codes copied",
description: "Store these codes in a safe place.",
});
setTimeout(() => setCopiedBackupCodes(false), 2000);
} catch (err) {
console.error("Failed to copy backup codes:", err);
}
};
const handleVerify = async () => {
if (verificationCode.length !== 6) {
setVerifyError("Please enter a 6-digit code");
return;
}
setIsLoading(true);
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.");
}
} catch (err) {
console.error("TOTP verification failed:", err);
setVerifyError("Verification failed. Please try again.");
} finally {
setIsLoading(false);
}
};
const handleComplete = () => {
onSuccess();
handleClose(false);
toast({
title: "Two-factor authentication enabled",
description: "Your account is now protected with TOTP.",
});
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && step === "verify" && verificationCode.length === 6) {
handleVerify();
}
};
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Smartphone className="w-5 h-5" />
{step === "setup" && "Set up Authenticator App"}
{step === "verify" && "Verify Setup"}
{step === "backup-codes" && "Save Backup Codes"}
{step === "success" && "Setup Complete"}
</DialogTitle>
<DialogDescription>
{step === "setup" && "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"}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{step === "setup" && (
<>
<div className="flex justify-center p-4 bg-white rounded-lg">
<img
src={mockQrCode}
alt="TOTP QR Code"
className="w-48 h-48"
/>
</div>
<div className="space-y-2">
<Label className="text-xs text-muted-foreground">
Can't scan? Enter this code manually:
</Label>
<div className="flex items-center gap-2">
<code className="flex-1 px-3 py-2 text-sm font-mono bg-muted rounded-md break-all">
{mockSecret}
</code>
<Button
variant="outline"
size="icon"
onClick={handleCopySecret}
>
{copiedSecret ? (
<CheckCircle className="w-4 h-4 text-success" />
) : (
<Copy className="w-4 h-4" />
)}
</Button>
</div>
</div>
<div className="flex justify-end gap-2 pt-2">
<Button variant="outline" onClick={() => handleClose(false)}>
Cancel
</Button>
<Button onClick={() => setStep("verify")}>
Continue
</Button>
</div>
</>
)}
{step === "verify" && (
<>
<div className="space-y-2">
<Label htmlFor="totp-code">Verification code</Label>
<Input
id="totp-code"
type="text"
inputMode="numeric"
pattern="[0-9]*"
maxLength={6}
placeholder="000000"
value={verificationCode}
onChange={(e) => {
const value = e.target.value.replace(/\D/g, "");
setVerificationCode(value);
setVerifyError(null);
}}
onKeyDown={handleKeyDown}
disabled={isLoading}
className="text-center text-2xl tracking-widest font-mono"
autoFocus
/>
{verifyError && (
<div className="flex items-center gap-2 text-sm text-destructive">
<AlertCircle className="w-4 h-4" />
{verifyError}
</div>
)}
</div>
<p className="text-xs text-muted-foreground">
Open your authenticator app and enter the 6-digit code shown for Gatehouse.
</p>
<div className="flex justify-end gap-2 pt-2">
<Button
variant="outline"
onClick={() => {
setStep("setup");
setVerificationCode("");
setVerifyError(null);
}}
disabled={isLoading}
>
Back
</Button>
<Button
onClick={handleVerify}
disabled={isLoading || verificationCode.length !== 6}
>
{isLoading && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
Verify
</Button>
</div>
</>
)}
{step === "backup-codes" && (
<>
<div className="p-4 bg-warning/10 border border-warning/30 rounded-lg">
<div className="flex gap-2 text-warning">
<AlertCircle className="w-5 h-5 flex-shrink-0" />
<div className="text-sm">
<p className="font-medium">Save these backup codes</p>
<p className="text-warning/80">
You'll need these if you lose access to your authenticator app.
Each code can only be used once.
</p>
</div>
</div>
</div>
<div className="grid grid-cols-2 gap-2 p-4 bg-muted rounded-lg">
{mockBackupCodes.map((code, index) => (
<code
key={index}
className="px-2 py-1 text-sm font-mono text-center bg-background rounded"
>
{code}
</code>
))}
</div>
<Button
variant="outline"
className="w-full"
onClick={handleCopyBackupCodes}
>
{copiedBackupCodes ? (
<>
<CheckCircle className="w-4 h-4 mr-2 text-success" />
Copied!
</>
) : (
<>
<Copy className="w-4 h-4 mr-2" />
Copy all codes
</>
)}
</Button>
<div className="flex justify-end gap-2 pt-2">
<Button onClick={handleComplete}>
<ShieldCheck className="w-4 h-4 mr-2" />
Done
</Button>
</div>
</>
)}
</div>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,104 @@
import { useState } from "react";
import { AlertTriangle, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
AlertDialog,
AlertDialogContent,
AlertDialogDescription,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { useToast } from "@/hooks/use-toast";
interface TotpRemoveDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onSuccess: () => void;
isRequired?: boolean;
}
export function TotpRemoveDialog({
open,
onOpenChange,
onSuccess,
isRequired = false,
}: TotpRemoveDialogProps) {
const [isLoading, setIsLoading] = useState(false);
const { toast } = useToast();
const handleRemove = async () => {
setIsLoading(true);
try {
// TODO: Call actual API to remove TOTP
// await api.users.removeTotpEnrollment();
await new Promise((resolve) => setTimeout(resolve, 1000));
toast({
title: "Two-factor authentication disabled",
description: "TOTP has been removed from your account.",
});
onSuccess();
onOpenChange(false);
} catch (err) {
console.error("Failed to remove TOTP:", err);
toast({
title: "Failed to remove TOTP",
description: "An error occurred. Please try again.",
variant: "destructive",
});
} finally {
setIsLoading(false);
}
};
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle className="flex items-center gap-2">
<AlertTriangle className="w-5 h-5 text-destructive" />
Remove Two-Factor Authentication?
</AlertDialogTitle>
<AlertDialogDescription className="space-y-3">
<p>
This will disable TOTP-based two-factor authentication for your account.
Your backup codes will also be invalidated.
</p>
{isRequired && (
<div className="p-3 bg-destructive/10 border border-destructive/30 rounded-lg text-destructive text-sm">
<strong>Warning:</strong> Your organization requires two-factor authentication.
You may lose access to certain features if you disable it.
</div>
)}
<p className="text-sm">
Are you sure you want to continue?
</p>
</AlertDialogDescription>
</AlertDialogHeader>
<div className="flex justify-end gap-2 mt-4">
<Button
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isLoading}
>
Cancel
</Button>
<Button
variant="destructive"
onClick={handleRemove}
disabled={isLoading}
>
{isLoading && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
Remove TOTP
</Button>
</div>
</AlertDialogContent>
</AlertDialog>
);
}
+48 -7
View File
@@ -6,6 +6,8 @@ import { Label } from "@/components/ui/label";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
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 { useToast } from "@/hooks/use-toast";
@@ -13,6 +15,8 @@ import { useToast } from "@/hooks/use-toast";
export default function SecurityPage() {
const [showPasswordForm, setShowPasswordForm] = useState(false);
const [showAddPasskey, setShowAddPasskey] = useState(false);
const [showTotpEnrollment, setShowTotpEnrollment] = useState(false);
const [showTotpRemove, setShowTotpRemove] = useState(false);
// Password form state
const [currentPassword, setCurrentPassword] = useState("");
@@ -21,12 +25,14 @@ export default function SecurityPage() {
const [isChangingPassword, setIsChangingPassword] = useState(false);
const [passwordError, setPasswordError] = useState<string | null>(null);
// TOTP state
const [totpEnabled, setTotpEnabled] = useState(false);
const { toast } = useToast();
// Mock security data
const security = {
passwordLastChanged: "3 months ago",
totpEnabled: true,
passkeysCount: 2,
passkeys: [
{ id: "1", name: "MacBook Pro Touch ID", lastUsed: "Today" },
@@ -228,21 +234,37 @@ export default function SecurityPage() {
Use an authenticator app for two-factor authentication
</CardDescription>
</div>
{security.totpEnabled ? (
{totpEnabled ? (
<Badge className="bg-success/10 text-success border-0">
<CheckCircle className="w-3 h-3 mr-1" />
Enabled
</Badge>
) : (
<Button size="sm">Set up</Button>
<Button size="sm" onClick={() => setShowTotpEnrollment(true)}>
Set up
</Button>
)}
</div>
</CardHeader>
{security.totpEnabled && (
{totpEnabled && (
<CardContent className="border-t pt-4">
<Button variant="outline" size="sm">
Reconfigure
</Button>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setShowTotpEnrollment(true)}
>
Reconfigure
</Button>
<Button
variant="outline"
size="sm"
className="text-destructive hover:text-destructive"
onClick={() => setShowTotpRemove(true)}
>
Remove
</Button>
</div>
</CardContent>
)}
</Card>
@@ -303,6 +325,25 @@ export default function SecurityPage() {
setShowAddPasskey(false);
}}
/>
<TotpEnrollmentWizard
open={showTotpEnrollment}
onOpenChange={setShowTotpEnrollment}
onSuccess={() => {
setTotpEnabled(true);
setShowTotpEnrollment(false);
}}
/>
<TotpRemoveDialog
open={showTotpRemove}
onOpenChange={setShowTotpRemove}
onSuccess={() => {
setTotpEnabled(false);
setShowTotpRemove(false);
}}
isRequired={security.policyRequirements.totpRequired}
/>
</div>
);
}