This commit is contained in:
gpt-engineer-app[bot]
2026-01-12 06:28:36 +00:00
parent b82abaa423
commit 872e720b9a
4 changed files with 256 additions and 71 deletions
@@ -1,4 +1,4 @@
import { useState } from "react"; import { useState, useEffect } from "react";
import { Smartphone, Copy, CheckCircle, Loader2, AlertCircle, ShieldCheck } from "lucide-react"; import { Smartphone, Copy, CheckCircle, Loader2, AlertCircle, ShieldCheck } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
@@ -11,8 +11,9 @@ import {
DialogTitle, DialogTitle,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { useToast } from "@/hooks/use-toast"; 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 { interface TotpEnrollmentWizardProps {
open: boolean; open: boolean;
@@ -25,35 +26,56 @@ export function TotpEnrollmentWizard({
onOpenChange, onOpenChange,
onSuccess, onSuccess,
}: TotpEnrollmentWizardProps) { }: TotpEnrollmentWizardProps) {
const [step, setStep] = useState<WizardStep>("setup"); const [step, setStep] = useState<WizardStep>("loading");
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [verificationCode, setVerificationCode] = useState(""); const [verificationCode, setVerificationCode] = useState("");
const [verifyError, setVerifyError] = useState<string | null>(null); const [verifyError, setVerifyError] = useState<string | null>(null);
const [copiedSecret, setCopiedSecret] = useState(false); const [copiedSecret, setCopiedSecret] = useState(false);
const [copiedBackupCodes, setCopiedBackupCodes] = useState(false); const [copiedBackupCodes, setCopiedBackupCodes] = useState(false);
const [enrollmentData, setEnrollmentData] = useState<TotpEnrollResponse | null>(null);
const [enrollError, setEnrollError] = useState<string | null>(null);
const { toast } = useToast(); const { toast } = useToast();
// Mock data - will be replaced with actual API calls // Fetch enrollment data when dialog opens
const mockSecret = "JBSWY3DPEHPK3PXP"; useEffect(() => {
const mockQrCode = `https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=otpauth://totp/Gatehouse:user@example.com?secret=${mockSecret}&issuer=Gatehouse`; if (open) {
const mockBackupCodes = [ initiateEnrollment();
"ABCD-1234-EFGH", }
"IJKL-5678-MNOP", }, [open]);
"QRST-9012-UVWX",
"YZAB-3456-CDEF", const initiateEnrollment = async () => {
"GHIJ-7890-KLMN", setStep("loading");
"OPQR-1234-STUV", setEnrollError(null);
"WXYZ-5678-ABCD", setEnrollmentData(null);
"EFGH-9012-IJKL",
]; 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 = () => { const resetWizard = () => {
setStep("setup"); setStep("loading");
setVerificationCode(""); setVerificationCode("");
setVerifyError(null); setVerifyError(null);
setCopiedSecret(false); setCopiedSecret(false);
setCopiedBackupCodes(false); setCopiedBackupCodes(false);
setIsLoading(false); setIsLoading(false);
setEnrollmentData(null);
setEnrollError(null);
}; };
const handleClose = (isOpen: boolean) => { const handleClose = (isOpen: boolean) => {
@@ -64,8 +86,9 @@ export function TotpEnrollmentWizard({
}; };
const handleCopySecret = async () => { const handleCopySecret = async () => {
if (!enrollmentData) return;
try { try {
await navigator.clipboard.writeText(mockSecret); await navigator.clipboard.writeText(enrollmentData.secret);
setCopiedSecret(true); setCopiedSecret(true);
setTimeout(() => setCopiedSecret(false), 2000); setTimeout(() => setCopiedSecret(false), 2000);
} catch (err) { } catch (err) {
@@ -74,8 +97,9 @@ export function TotpEnrollmentWizard({
}; };
const handleCopyBackupCodes = async () => { const handleCopyBackupCodes = async () => {
if (!enrollmentData) return;
try { try {
await navigator.clipboard.writeText(mockBackupCodes.join("\n")); await navigator.clipboard.writeText(enrollmentData.backup_codes.join("\n"));
setCopiedBackupCodes(true); setCopiedBackupCodes(true);
toast({ toast({
title: "Backup codes copied", title: "Backup codes copied",
@@ -97,20 +121,15 @@ export function TotpEnrollmentWizard({
setVerifyError(null); setVerifyError(null);
try { try {
// TODO: Call actual API to verify TOTP code await api.totp.verifyEnrollment(verificationCode);
// await api.users.verifyTotpEnrollment(verificationCode); setStep("backup-codes");
// 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) { } catch (err) {
console.error("TOTP verification failed:", 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 { } finally {
setIsLoading(false); setIsLoading(false);
} }
@@ -137,13 +156,15 @@ export function TotpEnrollmentWizard({
<DialogHeader> <DialogHeader>
<DialogTitle className="flex items-center gap-2"> <DialogTitle className="flex items-center gap-2">
<Smartphone className="w-5 h-5" /> <Smartphone className="w-5 h-5" />
{step === "loading" && "Setting up..."}
{step === "setup" && "Set up Authenticator App"} {step === "setup" && "Set up Authenticator App"}
{step === "verify" && "Verify Setup"} {step === "verify" && "Verify Setup"}
{step === "backup-codes" && "Save Backup Codes"} {step === "backup-codes" && "Save Backup Codes"}
{step === "success" && "Setup Complete"} {step === "success" && "Setup Complete"}
</DialogTitle> </DialogTitle>
<DialogDescription> <DialogDescription>
{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 === "verify" && "Enter the code from your authenticator app"}
{step === "backup-codes" && "Save these codes in a safe place"} {step === "backup-codes" && "Save these codes in a safe place"}
{step === "success" && "Two-factor authentication is now enabled"} {step === "success" && "Two-factor authentication is now enabled"}
@@ -151,11 +172,40 @@ export function TotpEnrollmentWizard({
</DialogHeader> </DialogHeader>
<div className="space-y-4"> <div className="space-y-4">
{step === "setup" && ( {step === "loading" && (
<div className="flex flex-col items-center justify-center py-8">
<Loader2 className="w-8 h-8 animate-spin text-muted-foreground" />
<p className="mt-2 text-sm text-muted-foreground">Preparing enrollment...</p>
</div>
)}
{step === "setup" && enrollError && (
<>
<div className="p-4 bg-destructive/10 border border-destructive/30 rounded-lg">
<div className="flex gap-2 text-destructive">
<AlertCircle className="w-5 h-5 flex-shrink-0" />
<div className="text-sm">
<p className="font-medium">Enrollment failed</p>
<p className="text-destructive/80">{enrollError}</p>
</div>
</div>
</div>
<div className="flex justify-end gap-2 pt-2">
<Button variant="outline" onClick={() => handleClose(false)}>
Cancel
</Button>
<Button onClick={initiateEnrollment}>
Try again
</Button>
</div>
</>
)}
{step === "setup" && !enrollError && enrollmentData && (
<> <>
<div className="flex justify-center p-4 bg-white rounded-lg"> <div className="flex justify-center p-4 bg-white rounded-lg">
<img <img
src={mockQrCode} src={`data:image/png;base64,${enrollmentData.qr_code}`}
alt="TOTP QR Code" alt="TOTP QR Code"
className="w-48 h-48" className="w-48 h-48"
/> />
@@ -167,7 +217,7 @@ export function TotpEnrollmentWizard({
</Label> </Label>
<div className="flex items-center gap-2"> <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"> <code className="flex-1 px-3 py-2 text-sm font-mono bg-muted rounded-md break-all">
{mockSecret} {enrollmentData.secret}
</code> </code>
<Button <Button
variant="outline" variant="outline"
@@ -251,7 +301,7 @@ export function TotpEnrollmentWizard({
</> </>
)} )}
{step === "backup-codes" && ( {step === "backup-codes" && enrollmentData && (
<> <>
<div className="p-4 bg-warning/10 border border-warning/30 rounded-lg"> <div className="p-4 bg-warning/10 border border-warning/30 rounded-lg">
<div className="flex gap-2 text-warning"> <div className="flex gap-2 text-warning">
@@ -267,7 +317,7 @@ export function TotpEnrollmentWizard({
</div> </div>
<div className="grid grid-cols-2 gap-2 p-4 bg-muted rounded-lg"> <div className="grid grid-cols-2 gap-2 p-4 bg-muted rounded-lg">
{mockBackupCodes.map((code, index) => ( {enrollmentData.backup_codes.map((code, index) => (
<code <code
key={index} key={index}
className="px-2 py-1 text-sm font-mono text-center bg-background rounded" className="px-2 py-1 text-sm font-mono text-center bg-background rounded"
+80 -31
View File
@@ -1,6 +1,8 @@
import { useState } from "react"; import { useState } from "react";
import { AlertTriangle, Loader2 } from "lucide-react"; import { AlertTriangle, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { import {
AlertDialog, AlertDialog,
AlertDialogContent, AlertDialogContent,
@@ -9,6 +11,7 @@ import {
AlertDialogTitle, AlertDialogTitle,
} from "@/components/ui/alert-dialog"; } from "@/components/ui/alert-dialog";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
import { api, ApiError } from "@/lib/api";
interface TotpRemoveDialogProps { interface TotpRemoveDialogProps {
open: boolean; open: boolean;
@@ -24,16 +27,34 @@ export function TotpRemoveDialog({
isRequired = false, isRequired = false,
}: TotpRemoveDialogProps) { }: TotpRemoveDialogProps) {
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const { toast } = useToast(); const { toast } = useToast();
const resetDialog = () => {
setPassword("");
setError(null);
setIsLoading(false);
};
const handleClose = (isOpen: boolean) => {
if (!isOpen) {
resetDialog();
}
onOpenChange(isOpen);
};
const handleRemove = async () => { const handleRemove = async () => {
if (!password) {
setError("Password is required to disable TOTP");
return;
}
setIsLoading(true); setIsLoading(true);
setError(null);
try { try {
// TODO: Call actual API to remove TOTP await api.totp.disable(password);
// await api.users.removeTotpEnrollment();
await new Promise((resolve) => setTimeout(resolve, 1000));
toast({ toast({
title: "Two-factor authentication disabled", title: "Two-factor authentication disabled",
@@ -41,21 +62,31 @@ export function TotpRemoveDialog({
}); });
onSuccess(); onSuccess();
onOpenChange(false); handleClose(false);
} catch (err) { } catch (err) {
console.error("Failed to remove TOTP:", err); console.error("Failed to remove TOTP:", err);
toast({ if (err instanceof ApiError) {
title: "Failed to remove TOTP", if (err.type === "INVALID_CREDENTIALS" || err.code === 401) {
description: "An error occurred. Please try again.", setError("Incorrect password. Please try again.");
variant: "destructive", } else {
}); setError(err.message);
}
} else {
setError("An error occurred. Please try again.");
}
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
}; };
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && password) {
handleRemove();
}
};
return ( return (
<AlertDialog open={open} onOpenChange={onOpenChange}> <AlertDialog open={open} onOpenChange={handleClose}>
<AlertDialogContent> <AlertDialogContent>
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle className="flex items-center gap-2"> <AlertDialogTitle className="flex items-center gap-2">
@@ -74,29 +105,47 @@ export function TotpRemoveDialog({
You may lose access to certain features if you disable it. You may lose access to certain features if you disable it.
</div> </div>
)} )}
<p className="text-sm">
Are you sure you want to continue?
</p>
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<div className="flex justify-end gap-2 mt-4"> <div className="space-y-4 mt-4">
<Button <div className="space-y-2">
variant="outline" <Label htmlFor="password-confirm">Enter your password to confirm</Label>
onClick={() => onOpenChange(false)} <Input
disabled={isLoading} id="password-confirm"
> type="password"
Cancel placeholder="Your current password"
</Button> value={password}
<Button onChange={(e) => {
variant="destructive" setPassword(e.target.value);
onClick={handleRemove} setError(null);
disabled={isLoading} }}
> onKeyDown={handleKeyDown}
{isLoading && <Loader2 className="w-4 h-4 mr-2 animate-spin" />} disabled={isLoading}
Remove TOTP autoFocus
</Button> />
{error && (
<p className="text-sm text-destructive">{error}</p>
)}
</div>
<div className="flex justify-end gap-2">
<Button
variant="outline"
onClick={() => handleClose(false)}
disabled={isLoading}
>
Cancel
</Button>
<Button
variant="destructive"
onClick={handleRemove}
disabled={isLoading || !password}
>
{isLoading && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
Remove TOTP
</Button>
</div>
</div> </div>
</AlertDialogContent> </AlertDialogContent>
</AlertDialog> </AlertDialog>
+60
View File
@@ -48,6 +48,26 @@ export interface LoginResponse {
user: User; user: User;
token: string; token: string;
expires_at: 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 { export interface ProfileResponse {
@@ -199,6 +219,46 @@ export const api = {
}), }),
}), }),
}, },
totp: {
// Initiate TOTP enrollment - returns secret, QR code, and backup codes
enroll: () =>
request<TotpEnrollResponse>('/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<TotpVerifyResponse>('/auth/totp/verify', {
method: 'POST',
body: JSON.stringify({ code, is_backup_code: isBackupCode }),
}, false),
// Get TOTP status
status: () =>
request<TotpStatusResponse>('/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 }; export { ApiError };
+29 -3
View File
@@ -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 { Lock, Fingerprint, Smartphone, Shield, Plus, CheckCircle, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
@@ -9,7 +9,7 @@ import { AddPasskeyWizard } from "@/components/security/AddPasskeyWizard";
import { TotpEnrollmentWizard } from "@/components/security/TotpEnrollmentWizard"; import { TotpEnrollmentWizard } from "@/components/security/TotpEnrollmentWizard";
import { TotpRemoveDialog } from "@/components/security/TotpRemoveDialog"; import { TotpRemoveDialog } from "@/components/security/TotpRemoveDialog";
import { PasswordStrengthMeter, isPasswordValid } from "@/components/auth/PasswordStrengthMeter"; 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"; import { useToast } from "@/hooks/use-toast";
export default function SecurityPage() { export default function SecurityPage() {
@@ -27,9 +27,31 @@ export default function SecurityPage() {
// TOTP state // TOTP state
const [totpEnabled, setTotpEnabled] = useState(false); const [totpEnabled, setTotpEnabled] = useState(false);
const [totpStatus, setTotpStatus] = useState<TotpStatusResponse | null>(null);
const [isTotpStatusLoading, setIsTotpStatusLoading] = useState(true);
const { toast } = useToast(); 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 // Mock security data
const security = { const security = {
passwordLastChanged: "3 months ago", passwordLastChanged: "3 months ago",
@@ -234,7 +256,9 @@ export default function SecurityPage() {
Use an authenticator app for two-factor authentication Use an authenticator app for two-factor authentication
</CardDescription> </CardDescription>
</div> </div>
{totpEnabled ? ( {isTotpStatusLoading ? (
<Loader2 className="w-4 h-4 animate-spin text-muted-foreground" />
) : totpEnabled ? (
<Badge className="bg-success/10 text-success border-0"> <Badge className="bg-success/10 text-success border-0">
<CheckCircle className="w-3 h-3 mr-1" /> <CheckCircle className="w-3 h-3 mr-1" />
Enabled Enabled
@@ -332,6 +356,7 @@ export default function SecurityPage() {
onSuccess={() => { onSuccess={() => {
setTotpEnabled(true); setTotpEnabled(true);
setShowTotpEnrollment(false); setShowTotpEnrollment(false);
fetchTotpStatus(); // Refresh status after enrollment
}} }}
/> />
@@ -340,6 +365,7 @@ export default function SecurityPage() {
onOpenChange={setShowTotpRemove} onOpenChange={setShowTotpRemove}
onSuccess={() => { onSuccess={() => {
setTotpEnabled(false); setTotpEnabled(false);
setTotpStatus(null);
setShowTotpRemove(false); setShowTotpRemove(false);
}} }}
isRequired={security.policyRequirements.totpRequired} isRequired={security.policyRequirements.totpRequired}