Wire up TOTP endpoints
Enable real TOTP flow by integrating enroll/verify/backup codes API, updating TotpEnrollmentWizard and TotpRemoveDialog to use backend, and connect SecurityPage to live status. Replaces mock data with API calls, adds status refresh after enrollment, and wires removal to API with UI confirmations. X-Lovable-Edit-ID: edt-3f2bb4a3-06ff-406a-bc2c-d4c70de452a1
This commit is contained in:
@@ -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);
|
|
||||||
|
|
||||||
// Mock verification - accept "123456" for testing
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
||||||
|
|
||||||
if (verificationCode === "123456") {
|
|
||||||
setStep("backup-codes");
|
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);
|
||||||
|
if (err instanceof ApiError) {
|
||||||
|
setVerifyError(err.message || "Invalid verification code. Please try again.");
|
||||||
|
} else {
|
||||||
setVerifyError("Verification failed. Please try again.");
|
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"
|
||||||
|
|||||||
@@ -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,17 +105,34 @@ 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">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="password-confirm">Enter your password to confirm</Label>
|
||||||
|
<Input
|
||||||
|
id="password-confirm"
|
||||||
|
type="password"
|
||||||
|
placeholder="Your current password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => {
|
||||||
|
setPassword(e.target.value);
|
||||||
|
setError(null);
|
||||||
|
}}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
disabled={isLoading}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
{error && (
|
||||||
|
<p className="text-sm text-destructive">{error}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => onOpenChange(false)}
|
onClick={() => handleClose(false)}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
@@ -92,12 +140,13 @@ export function TotpRemoveDialog({
|
|||||||
<Button
|
<Button
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
onClick={handleRemove}
|
onClick={handleRemove}
|
||||||
disabled={isLoading}
|
disabled={isLoading || !password}
|
||||||
>
|
>
|
||||||
{isLoading && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
|
{isLoading && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
|
||||||
Remove TOTP
|
Remove TOTP
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
Reference in New Issue
Block a user