339 lines
14 KiB
TypeScript
339 lines
14 KiB
TypeScript
"use client";
|
|
|
|
import { useState } from "react";
|
|
import { SettingsSection, SettingsRow, SettingsInput } from "../ui";
|
|
import { useAuth } from "@/lib/auth-context";
|
|
import { api } from "@/lib/api";
|
|
import { useTwoFactor } from "../../hooks/useTwoFactor";
|
|
import { Modal } from "@/components/ui/Modal";
|
|
import { InlineStatus, StatusType } from "@/components/ui/InlineStatus";
|
|
|
|
export function AccountSection() {
|
|
const { user } = useAuth();
|
|
|
|
// Password change state
|
|
const [currentPassword, setCurrentPassword] = useState("");
|
|
const [newPassword, setNewPassword] = useState("");
|
|
const [confirmPassword, setConfirmPassword] = useState("");
|
|
const [changingPassword, setChangingPassword] = useState(false);
|
|
const [showPasswordForm, setShowPasswordForm] = useState(false);
|
|
const [passwordStatus, setPasswordStatus] = useState<StatusType>("idle");
|
|
const [passwordMessage, setPasswordMessage] = useState("");
|
|
|
|
// 2FA state
|
|
const {
|
|
twoFactorEnabled,
|
|
settingUpTwoFactor,
|
|
twoFactorQR,
|
|
twoFactorSecret,
|
|
recoveryCodes,
|
|
showRecoveryCodes,
|
|
load2FAStatus,
|
|
setup2FA,
|
|
enable2FA,
|
|
disable2FA,
|
|
cancel2FASetup,
|
|
closeRecoveryCodes,
|
|
} = useTwoFactor();
|
|
|
|
const [twoFactorToken, setTwoFactorToken] = useState("");
|
|
const [disablePassword, setDisablePassword] = useState("");
|
|
const [disableToken, setDisableToken] = useState("");
|
|
const [showDisableFlow, setShowDisableFlow] = useState(false);
|
|
const [tfaStatus, setTfaStatus] = useState<StatusType>("idle");
|
|
const [tfaMessage, setTfaMessage] = useState("");
|
|
|
|
// Handle password change
|
|
const handleChangePassword = async () => {
|
|
if (!currentPassword || !newPassword || !confirmPassword) {
|
|
setPasswordStatus("error");
|
|
setPasswordMessage("All fields required");
|
|
return;
|
|
}
|
|
if (newPassword.length < 6) {
|
|
setPasswordStatus("error");
|
|
setPasswordMessage("Min 6 characters");
|
|
return;
|
|
}
|
|
if (newPassword !== confirmPassword) {
|
|
setPasswordStatus("error");
|
|
setPasswordMessage("Passwords don't match");
|
|
return;
|
|
}
|
|
|
|
setChangingPassword(true);
|
|
setPasswordStatus("loading");
|
|
try {
|
|
await api.post("/auth/change-password", {
|
|
currentPassword,
|
|
newPassword,
|
|
});
|
|
setPasswordStatus("success");
|
|
setPasswordMessage("Changed");
|
|
setCurrentPassword("");
|
|
setNewPassword("");
|
|
setConfirmPassword("");
|
|
setTimeout(() => setShowPasswordForm(false), 1500);
|
|
} catch (error: any) {
|
|
setPasswordStatus("error");
|
|
setPasswordMessage(error.response?.data?.message || "Failed");
|
|
} finally {
|
|
setChangingPassword(false);
|
|
}
|
|
};
|
|
|
|
// Handle 2FA verification
|
|
const handleVerify2FA = async () => {
|
|
setTfaStatus("loading");
|
|
try {
|
|
await enable2FA(twoFactorToken);
|
|
setTfaStatus("success");
|
|
setTfaMessage("Enabled");
|
|
setTwoFactorToken("");
|
|
} catch (error: any) {
|
|
setTfaStatus("error");
|
|
setTfaMessage(error.message || "Invalid code");
|
|
}
|
|
};
|
|
|
|
// Handle 2FA disable
|
|
const handleDisable2FA = async () => {
|
|
setTfaStatus("loading");
|
|
try {
|
|
await disable2FA(disablePassword, disableToken);
|
|
setTfaStatus("success");
|
|
setTfaMessage("Disabled");
|
|
setDisablePassword("");
|
|
setDisableToken("");
|
|
setShowDisableFlow(false);
|
|
} catch (error: any) {
|
|
setTfaStatus("error");
|
|
setTfaMessage(error.message || "Failed");
|
|
}
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<SettingsSection id="account" title="Account">
|
|
{/* Username Display */}
|
|
<SettingsRow label="Username" description={`Logged in as ${user?.username}`}>
|
|
<span className="text-sm text-gray-400">{user?.role}</span>
|
|
</SettingsRow>
|
|
|
|
{/* Change Password */}
|
|
<SettingsRow
|
|
label="Password"
|
|
description="Change your account password"
|
|
>
|
|
{!showPasswordForm ? (
|
|
<button
|
|
onClick={() => setShowPasswordForm(true)}
|
|
className="text-sm text-white hover:underline"
|
|
>
|
|
Change
|
|
</button>
|
|
) : (
|
|
<button
|
|
onClick={() => setShowPasswordForm(false)}
|
|
className="text-sm text-gray-400 hover:text-white"
|
|
>
|
|
Cancel
|
|
</button>
|
|
)}
|
|
</SettingsRow>
|
|
|
|
{showPasswordForm && (
|
|
<div className="py-4 space-y-3 border-t border-b border-white/5">
|
|
<SettingsInput
|
|
type="password"
|
|
value={currentPassword}
|
|
onChange={setCurrentPassword}
|
|
placeholder="Current password"
|
|
/>
|
|
<SettingsInput
|
|
type="password"
|
|
value={newPassword}
|
|
onChange={setNewPassword}
|
|
placeholder="New password (min 6 characters)"
|
|
/>
|
|
<SettingsInput
|
|
type="password"
|
|
value={confirmPassword}
|
|
onChange={setConfirmPassword}
|
|
placeholder="Confirm new password"
|
|
/>
|
|
<div className="inline-flex items-center gap-3">
|
|
<button
|
|
onClick={handleChangePassword}
|
|
disabled={changingPassword || !currentPassword || !newPassword || newPassword !== confirmPassword}
|
|
className="px-4 py-2 bg-white text-black text-sm font-medium rounded-full
|
|
hover:scale-105 disabled:opacity-50 disabled:cursor-not-allowed transition-transform"
|
|
>
|
|
{changingPassword ? "Changing..." : "Change Password"}
|
|
</button>
|
|
<InlineStatus
|
|
status={passwordStatus}
|
|
message={passwordMessage}
|
|
onClear={() => setPasswordStatus("idle")}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Two-Factor Authentication */}
|
|
<SettingsRow
|
|
label="Two-factor authentication"
|
|
description={twoFactorEnabled ? "Enabled" : "Add extra security to your account"}
|
|
>
|
|
{!settingUpTwoFactor && !showDisableFlow && (
|
|
twoFactorEnabled ? (
|
|
<button
|
|
onClick={() => setShowDisableFlow(true)}
|
|
className="text-sm text-red-400 hover:text-red-300"
|
|
>
|
|
Disable
|
|
</button>
|
|
) : (
|
|
<button
|
|
onClick={setup2FA}
|
|
className="text-sm text-white hover:underline"
|
|
>
|
|
Enable
|
|
</button>
|
|
)
|
|
)}
|
|
</SettingsRow>
|
|
|
|
{/* 2FA Setup Flow */}
|
|
{settingUpTwoFactor && (
|
|
<div className="py-4 space-y-4 border-t border-b border-white/5">
|
|
<p className="text-sm text-gray-400">
|
|
Scan the QR code with your authenticator app, then enter the code below.
|
|
</p>
|
|
|
|
{twoFactorQR && (
|
|
<div className="flex justify-center">
|
|
<div className="bg-white p-3 rounded-lg">
|
|
<img src={twoFactorQR} alt="2FA QR Code" className="w-40 h-40" />
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{twoFactorSecret && (
|
|
<div className="text-center">
|
|
<p className="text-xs text-gray-500 mb-1">Manual entry code:</p>
|
|
<code className="text-sm text-white bg-[#282828] px-3 py-1 rounded font-mono">
|
|
{twoFactorSecret}
|
|
</code>
|
|
</div>
|
|
)}
|
|
|
|
<SettingsInput
|
|
type="text"
|
|
value={twoFactorToken}
|
|
onChange={(v) => setTwoFactorToken(v.replace(/\D/g, "").slice(0, 6))}
|
|
placeholder="Enter 6-digit code"
|
|
/>
|
|
|
|
<div className="inline-flex items-center gap-3">
|
|
<button
|
|
onClick={handleVerify2FA}
|
|
disabled={twoFactorToken.length !== 6}
|
|
className="px-4 py-2 bg-white text-black text-sm font-medium rounded-full
|
|
hover:scale-105 disabled:opacity-50 disabled:cursor-not-allowed transition-transform"
|
|
>
|
|
Verify
|
|
</button>
|
|
<button
|
|
onClick={() => { cancel2FASetup(); setTwoFactorToken(""); }}
|
|
className="px-4 py-2 text-sm text-gray-400 hover:text-white"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<InlineStatus
|
|
status={tfaStatus}
|
|
message={tfaMessage}
|
|
onClear={() => setTfaStatus("idle")}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* 2FA Disable Flow */}
|
|
{showDisableFlow && (
|
|
<div className="py-4 space-y-3 border-t border-b border-white/5">
|
|
<p className="text-sm text-yellow-500">
|
|
Enter your password and current code to disable 2FA.
|
|
</p>
|
|
<SettingsInput
|
|
type="password"
|
|
value={disablePassword}
|
|
onChange={setDisablePassword}
|
|
placeholder="Password"
|
|
/>
|
|
<SettingsInput
|
|
type="text"
|
|
value={disableToken}
|
|
onChange={(v) => setDisableToken(v.replace(/\D/g, "").slice(0, 6))}
|
|
placeholder="6-digit code"
|
|
/>
|
|
<div className="inline-flex items-center gap-3">
|
|
<button
|
|
onClick={handleDisable2FA}
|
|
disabled={!disablePassword || disableToken.length !== 6}
|
|
className="px-4 py-2 bg-red-500 text-white text-sm font-medium rounded-full
|
|
hover:bg-red-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
|
>
|
|
Disable 2FA
|
|
</button>
|
|
<button
|
|
onClick={() => { setShowDisableFlow(false); setDisablePassword(""); setDisableToken(""); }}
|
|
className="px-4 py-2 text-sm text-gray-400 hover:text-white"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<InlineStatus
|
|
status={tfaStatus}
|
|
message={tfaMessage}
|
|
onClear={() => setTfaStatus("idle")}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</SettingsSection>
|
|
|
|
{/* Recovery Codes Modal */}
|
|
<Modal isOpen={showRecoveryCodes} onClose={closeRecoveryCodes} title="Recovery Codes">
|
|
<div className="space-y-4">
|
|
<div className="bg-red-500/10 border border-red-500/30 rounded-lg p-3">
|
|
<p className="text-sm text-red-300">
|
|
Save these codes! You'll need them if you lose your authenticator.
|
|
</p>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-2">
|
|
{recoveryCodes.map((code, i) => (
|
|
<code key={i} className="text-sm text-white bg-[#282828] px-3 py-2 rounded font-mono">
|
|
{code}
|
|
</code>
|
|
))}
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<button
|
|
onClick={() => navigator.clipboard.writeText(recoveryCodes.join("\n"))}
|
|
className="px-4 py-2 bg-[#333] text-white text-sm rounded-full hover:bg-[#404040]"
|
|
>
|
|
Copy
|
|
</button>
|
|
<button
|
|
onClick={closeRecoveryCodes}
|
|
className="px-4 py-2 bg-white text-black text-sm font-medium rounded-full hover:scale-105 transition-transform"
|
|
>
|
|
Done
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</Modal>
|
|
</>
|
|
);
|
|
}
|