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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user