mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-07-16 05:13:36 +00:00
feat: initial ui redesign from demo
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,215 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Button } from "@/components/button.tsx";
|
||||
import { VersionAlert } from "@/components/version-alert.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { isElectron } from "@/lib/electron";
|
||||
import { checkElectronUpdate } from "@/main-axios.ts";
|
||||
import { useTheme } from "@/components/theme-provider";
|
||||
|
||||
interface VersionCheckModalProps {
|
||||
onContinue: () => void;
|
||||
}
|
||||
|
||||
type ElectronWindow = Window & {
|
||||
electronAPI?: {
|
||||
getAppVersion?: () => Promise<string | undefined>;
|
||||
};
|
||||
};
|
||||
|
||||
export function ElectronVersionCheck({ onContinue }: VersionCheckModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const { theme } = useTheme();
|
||||
const [versionInfo, setVersionInfo] = useState<Record<
|
||||
string,
|
||||
unknown
|
||||
> | null>(null);
|
||||
const [versionChecking, setVersionChecking] = useState(false);
|
||||
const [versionDismissed] = useState(false);
|
||||
|
||||
const isDarkMode =
|
||||
theme === "dark" ||
|
||||
theme === "dracula" ||
|
||||
theme === "gentlemansChoice" ||
|
||||
theme === "midnightEspresso" ||
|
||||
theme === "catppuccinMocha" ||
|
||||
(theme === "system" &&
|
||||
window.matchMedia("(prefers-color-scheme: dark)").matches);
|
||||
const lineColor = isDarkMode ? "#151517" : "#f9f9f9";
|
||||
const versionModalTitle =
|
||||
versionInfo?.status === "beta"
|
||||
? t("versionCheck.betaVersion")
|
||||
: t("versionCheck.updateRequired");
|
||||
|
||||
useEffect(() => {
|
||||
const updateCheckDisabled =
|
||||
localStorage.getItem("disableUpdateCheck") === "true";
|
||||
if (updateCheckDisabled) {
|
||||
onContinue();
|
||||
return;
|
||||
}
|
||||
if (isElectron()) {
|
||||
checkForUpdates();
|
||||
} else {
|
||||
onContinue();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const checkForUpdates = async () => {
|
||||
setVersionChecking(true);
|
||||
try {
|
||||
const updateInfo = await checkElectronUpdate();
|
||||
setVersionInfo(updateInfo);
|
||||
|
||||
const currentVersion = await (
|
||||
window as ElectronWindow
|
||||
).electronAPI?.getAppVersion?.();
|
||||
const dismissedVersion = localStorage.getItem(
|
||||
"electron-version-check-dismissed",
|
||||
);
|
||||
|
||||
if (dismissedVersion === currentVersion) {
|
||||
onContinue();
|
||||
return;
|
||||
}
|
||||
|
||||
if (updateInfo?.status === "up_to_date") {
|
||||
if (currentVersion) {
|
||||
localStorage.setItem(
|
||||
"electron-version-check-dismissed",
|
||||
currentVersion,
|
||||
);
|
||||
}
|
||||
onContinue();
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to check for updates:", error);
|
||||
setVersionInfo({ success: false, error: "Check failed" });
|
||||
} finally {
|
||||
setVersionChecking(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownloadUpdate = () => {
|
||||
if (versionInfo?.latest_release?.html_url) {
|
||||
window.open(versionInfo.latest_release.html_url, "_blank");
|
||||
}
|
||||
};
|
||||
|
||||
const handleContinue = async () => {
|
||||
const currentVersion = await (
|
||||
window as ElectronWindow
|
||||
).electronAPI?.getAppVersion?.();
|
||||
if (currentVersion) {
|
||||
localStorage.setItem("electron-version-check-dismissed", currentVersion);
|
||||
}
|
||||
onContinue();
|
||||
};
|
||||
|
||||
if (!isElectron()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (versionChecking && !versionInfo) {
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 flex items-center justify-center z-50"
|
||||
style={{
|
||||
background: "var(--bg-elevated)",
|
||||
backgroundImage: `repeating-linear-gradient(
|
||||
45deg,
|
||||
transparent,
|
||||
transparent 35px,
|
||||
${lineColor} 35px,
|
||||
${lineColor} 37px
|
||||
)`,
|
||||
}}
|
||||
>
|
||||
<div className="w-[420px] max-w-full p-8 flex flex-col backdrop-blur-sm bg-card/50 rounded-2xl shadow-xl border-2 border-edge overflow-y-auto thin-scrollbar my-2 animate-in fade-in zoom-in-95 duration-300">
|
||||
<div className="flex items-center justify-center mb-4">
|
||||
<div className="w-8 h-8 border-2 border-primary border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
<p className="text-center text-muted-foreground">
|
||||
{t("versionCheck.checkingUpdates")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!versionInfo || versionDismissed) {
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 flex items-center justify-center z-50"
|
||||
style={{
|
||||
background: "var(--bg-elevated)",
|
||||
backgroundImage: `repeating-linear-gradient(
|
||||
45deg,
|
||||
transparent,
|
||||
transparent 35px,
|
||||
${lineColor} 35px,
|
||||
${lineColor} 37px
|
||||
)`,
|
||||
}}
|
||||
>
|
||||
<div className="w-[420px] max-w-full p-8 flex flex-col backdrop-blur-sm bg-card/50 rounded-2xl shadow-xl border-2 border-edge overflow-y-auto thin-scrollbar my-2 animate-in fade-in zoom-in-95 duration-300">
|
||||
<div className="mb-4">
|
||||
<h2 className="text-lg font-semibold">
|
||||
{t("versionCheck.checkUpdates")}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{versionInfo && !versionDismissed && (
|
||||
<div className="mb-4">
|
||||
<VersionAlert
|
||||
updateInfo={versionInfo}
|
||||
onDownload={handleDownloadUpdate}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={handleContinue} className="flex-1 h-10">
|
||||
{t("common.continue")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 flex items-center justify-center z-50"
|
||||
style={{
|
||||
background: "var(--bg-elevated)",
|
||||
backgroundImage: `repeating-linear-gradient(
|
||||
45deg,
|
||||
transparent,
|
||||
transparent 35px,
|
||||
${lineColor} 35px,
|
||||
${lineColor} 37px
|
||||
)`,
|
||||
}}
|
||||
>
|
||||
<div className="w-[420px] max-w-full p-8 flex flex-col backdrop-blur-sm bg-card/50 rounded-2xl shadow-xl border-2 border-edge overflow-y-auto thin-scrollbar my-2 animate-in fade-in zoom-in-95 duration-300">
|
||||
<div className="mb-4">
|
||||
<h2 className="text-lg font-semibold">{versionModalTitle}</h2>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<VersionAlert
|
||||
updateInfo={versionInfo}
|
||||
onDownload={handleDownloadUpdate}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={handleContinue} className="flex-1 h-10">
|
||||
{t("common.continue")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/select.tsx";
|
||||
import { Globe } from "lucide-react";
|
||||
|
||||
const languages = [
|
||||
{ code: "en", name: "English", nativeName: "English" },
|
||||
{ code: "af", name: "Afrikaans", nativeName: "Afrikaans" },
|
||||
{ code: "ar", name: "Arabic", nativeName: "العربية" },
|
||||
{ code: "bn", name: "Bengali", nativeName: "বাংলা" },
|
||||
{ code: "bg", name: "Bulgarian", nativeName: "Български" },
|
||||
{ code: "ca", name: "Catalan", nativeName: "Català" },
|
||||
{ code: "zh-CN", name: "Chinese Simplified", nativeName: "简体中文" },
|
||||
{ code: "zh-TW", name: "Chinese Traditional", nativeName: "繁体中文" },
|
||||
{ code: "cs", name: "Czech", nativeName: "Čeština" },
|
||||
{ code: "da", name: "Danish", nativeName: "Dansk" },
|
||||
{ code: "nl", name: "Dutch", nativeName: "Nederlands" },
|
||||
{ code: "fi", name: "Finnish", nativeName: "Suomi" },
|
||||
{ code: "fr", name: "French", nativeName: "Français" },
|
||||
{ code: "de", name: "German", nativeName: "Deutsch" },
|
||||
{ code: "el", name: "Greek", nativeName: "Ελληνικά" },
|
||||
{ code: "he", name: "Hebrew", nativeName: "עברית" },
|
||||
{ code: "hi", name: "Hindi", nativeName: "हिन्दी" },
|
||||
{ code: "hu", name: "Hungarian", nativeName: "Magyar" },
|
||||
{ code: "id", name: "Indonesian", nativeName: "Bahasa Indonesia" },
|
||||
{ code: "it", name: "Italian", nativeName: "Italiano" },
|
||||
{ code: "ja", name: "Japanese", nativeName: "日本語" },
|
||||
{ code: "ko", name: "Korean", nativeName: "한국어" },
|
||||
{ code: "no", name: "Norwegian", nativeName: "Norsk" },
|
||||
{ code: "pl", name: "Polish", nativeName: "Polski" },
|
||||
{
|
||||
code: "pt-PT",
|
||||
name: "Portuguese",
|
||||
nativeName: "Português",
|
||||
},
|
||||
{
|
||||
code: "pt-BR",
|
||||
name: "Portuguese (Brazil)",
|
||||
nativeName: "Português (Brasil)",
|
||||
},
|
||||
{ code: "ro", name: "Romanian", nativeName: "Română" },
|
||||
{ code: "ru", name: "Russian", nativeName: "Русский" },
|
||||
{ code: "sr", name: "Serbian", nativeName: "Српски" },
|
||||
{ code: "es-ES", name: "Spanish", nativeName: "Español" },
|
||||
{ code: "sv-SE", name: "Swedish", nativeName: "Svenska" },
|
||||
{ code: "th", name: "Thai", nativeName: "ไทย" },
|
||||
{ code: "tr", name: "Turkish", nativeName: "Türkçe" },
|
||||
{ code: "uk", name: "Ukrainian", nativeName: "Українська" },
|
||||
{ code: "vi", name: "Vietnamese", nativeName: "Tiếng Việt" },
|
||||
];
|
||||
|
||||
export function LanguageSwitcher() {
|
||||
const { i18n, t } = useTranslation();
|
||||
|
||||
const handleLanguageChange = (value: string) => {
|
||||
i18n.changeLanguage(value);
|
||||
localStorage.setItem("i18nextLng", value);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 relative z-[99999]">
|
||||
<Globe className="h-4 w-4 text-muted-foreground" />
|
||||
<Select value={i18n.language} onValueChange={handleLanguageChange}>
|
||||
<SelectTrigger className="w-[120px]">
|
||||
<SelectValue placeholder={t("placeholders.language")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="z-[99999]">
|
||||
{languages.map((lang) => (
|
||||
<SelectItem key={lang.code} value={lang.code}>
|
||||
{lang.nativeName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/card.tsx";
|
||||
import { Key } from "lucide-react";
|
||||
import React, { useState } from "react";
|
||||
import { changePassword } from "@/main-axios.ts";
|
||||
import { Label } from "@/components/label.tsx";
|
||||
import { PasswordInput } from "@/components/password-input.tsx";
|
||||
import { Button } from "@/components/button.tsx";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/alert.tsx";
|
||||
import { toast } from "sonner";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export function PasswordReset() {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [currentPassword, setCurrentPassword] = useState("");
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { t } = useTranslation();
|
||||
|
||||
async function handleChangePassword() {
|
||||
setError(null);
|
||||
|
||||
if (!currentPassword || !newPassword || !confirmPassword) {
|
||||
setError(t("errors.requiredField"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (newPassword !== confirmPassword) {
|
||||
setError(t("common.passwordsDoNotMatch"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (newPassword.length < 6) {
|
||||
setError(t("common.passwordMinLength"));
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
await changePassword(currentPassword, newPassword);
|
||||
toast.success(t("profile.passwordChangedSuccess"));
|
||||
window.location.reload();
|
||||
} catch (err: unknown) {
|
||||
const error = err as { response?: { data?: { error?: string } } };
|
||||
setError(
|
||||
error?.response?.data?.error || t("profile.failedToChangePassword"),
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
const Spinner = (
|
||||
<svg
|
||||
className="animate-spin mr-2 h-4 w-4 text-foreground inline-block"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
fill="none"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Key className="w-5 h-5" />
|
||||
{t("common.password")}
|
||||
</CardTitle>
|
||||
<CardDescription>{t("common.changeAccountPassword")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-col gap-5">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="current-password">
|
||||
{t("profile.currentPassword")}
|
||||
</Label>
|
||||
<PasswordInput
|
||||
id="current-password"
|
||||
required
|
||||
className="h-11 text-base"
|
||||
value={currentPassword}
|
||||
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||
disabled={loading}
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="new-password">{t("common.newPassword")}</Label>
|
||||
<PasswordInput
|
||||
id="new-password"
|
||||
required
|
||||
className="h-11 text-base"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
disabled={loading}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="confirm-password">
|
||||
{t("common.confirmPassword")}
|
||||
</Label>
|
||||
<PasswordInput
|
||||
id="confirm-password"
|
||||
required
|
||||
className="h-11 text-base"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
disabled={loading}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full h-11 text-base font-semibold mt-2"
|
||||
disabled={
|
||||
loading || !currentPassword || !newPassword || !confirmPassword
|
||||
}
|
||||
onClick={handleChangePassword}
|
||||
>
|
||||
{loading ? Spinner : t("profile.changePassword")}
|
||||
</Button>
|
||||
{error && (
|
||||
<Alert variant="destructive" className="mt-4">
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,479 @@
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/card.tsx";
|
||||
import { Button } from "@/components/button.tsx";
|
||||
import { Input } from "@/components/input.tsx";
|
||||
import { PasswordInput } from "@/components/password-input.tsx";
|
||||
import { Label } from "@/components/label.tsx";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/alert.tsx";
|
||||
import {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from "@/components/tabs.tsx";
|
||||
import {
|
||||
Shield,
|
||||
Copy,
|
||||
Download,
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
setupTOTP,
|
||||
enableTOTP,
|
||||
disableTOTP,
|
||||
generateBackupCodes,
|
||||
} from "@/main-axios.ts";
|
||||
import { toast } from "sonner";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface TOTPSetupProps {
|
||||
isEnabled: boolean;
|
||||
onStatusChange?: (enabled: boolean) => void;
|
||||
}
|
||||
|
||||
export function TOTPSetup({
|
||||
isEnabled: initialEnabled,
|
||||
onStatusChange,
|
||||
}: TOTPSetupProps) {
|
||||
const { t } = useTranslation();
|
||||
const [isEnabled, setIsEnabled] = useState(initialEnabled);
|
||||
const [isSettingUp, setIsSettingUp] = useState(false);
|
||||
const [setupStep, setSetupStep] = useState<
|
||||
"init" | "qr" | "verify" | "backup"
|
||||
>("init");
|
||||
const [qrCode, setQrCode] = useState("");
|
||||
const [secret, setSecret] = useState("");
|
||||
const [verificationCode, setVerificationCode] = useState("");
|
||||
const [backupCodes, setBackupCodes] = useState<string[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [password, setPassword] = useState("");
|
||||
const [disableCode, setDisableCode] = useState("");
|
||||
|
||||
const handleSetupStart = async () => {
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await setupTOTP();
|
||||
setQrCode(response.qr_code);
|
||||
setSecret(response.secret);
|
||||
setSetupStep("qr");
|
||||
setIsSettingUp(true);
|
||||
} catch (err: unknown) {
|
||||
const error = err as { response?: { data?: { error?: string } } };
|
||||
setError(error?.response?.data?.error || "Failed to start TOTP setup");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleVerifyCode = async () => {
|
||||
if (verificationCode.length !== 6) {
|
||||
setError("Please enter a 6-digit code");
|
||||
return;
|
||||
}
|
||||
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await enableTOTP(verificationCode);
|
||||
setBackupCodes(response.backup_codes);
|
||||
setSetupStep("backup");
|
||||
toast.success(t("auth.twoFactorEnabledSuccess"));
|
||||
} catch (err: unknown) {
|
||||
const error = err as { response?: { data?: { error?: string } } };
|
||||
setError(error?.response?.data?.error || "Invalid verification code");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDisable = async () => {
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
try {
|
||||
await disableTOTP(password || undefined, disableCode || undefined);
|
||||
setIsEnabled(false);
|
||||
setIsSettingUp(false);
|
||||
setSetupStep("init");
|
||||
setPassword("");
|
||||
setDisableCode("");
|
||||
onStatusChange?.(false);
|
||||
toast.success(t("auth.twoFactorDisabled"));
|
||||
} catch (err: unknown) {
|
||||
const error = err as { response?: { data?: { error?: string } } };
|
||||
setError(error?.response?.data?.error || "Failed to disable TOTP");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGenerateNewBackupCodes = async () => {
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await generateBackupCodes(
|
||||
password || undefined,
|
||||
disableCode || undefined,
|
||||
);
|
||||
setBackupCodes(response.backup_codes);
|
||||
toast.success(t("auth.newBackupCodesGenerated"));
|
||||
} catch (err: unknown) {
|
||||
const error = err as { response?: { data?: { error?: string } } };
|
||||
setError(
|
||||
error?.response?.data?.error || "Failed to generate backup codes",
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const copyToClipboard = (text: string, label: string) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
toast.success(t("messages.copiedToClipboard", { item: label }));
|
||||
};
|
||||
|
||||
const downloadBackupCodes = () => {
|
||||
const content =
|
||||
`Termix Two-Factor Authentication Backup Codes\n` +
|
||||
`Generated: ${new Date().toISOString()}\n\n` +
|
||||
`Keep these codes in a safe place. Each code can only be used once.\n\n` +
|
||||
backupCodes.map((code, i) => `${i + 1}. ${code}`).join("\n");
|
||||
|
||||
const blob = new Blob([content], { type: "text/plain" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "termix-backup-codes.txt";
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
toast.success(t("auth.backupCodesDownloaded"));
|
||||
};
|
||||
|
||||
const handleComplete = () => {
|
||||
setIsEnabled(true);
|
||||
setIsSettingUp(false);
|
||||
setSetupStep("init");
|
||||
setVerificationCode("");
|
||||
onStatusChange?.(true);
|
||||
};
|
||||
|
||||
if (isEnabled && !isSettingUp) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Shield className="w-5 h-5" />
|
||||
{t("auth.twoFactorTitle")}
|
||||
</CardTitle>
|
||||
<CardDescription>{t("auth.twoFactorProtected")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<Alert>
|
||||
<CheckCircle2 className="h-4 w-4" />
|
||||
<AlertTitle>{t("common.enabled")}</AlertTitle>
|
||||
<AlertDescription>{t("auth.twoFactorActive")}</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<Tabs defaultValue="disable" className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="disable">{t("auth.disable2FA")}</TabsTrigger>
|
||||
<TabsTrigger value="backup">{t("auth.backupCodes")}</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="disable" className="space-y-4">
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertTitle>{t("common.warning")}</AlertTitle>
|
||||
<AlertDescription>
|
||||
{t("auth.disableTwoFactorWarning")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="disable-password">
|
||||
{t("auth.passwordOrTotpCode")}
|
||||
</Label>
|
||||
<PasswordInput
|
||||
id="disable-password"
|
||||
placeholder={t("placeholders.enterPassword")}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">{t("auth.or")}</p>
|
||||
<Input
|
||||
id="disable-code"
|
||||
type="text"
|
||||
placeholder={t("placeholders.totpCode")}
|
||||
maxLength={6}
|
||||
value={disableCode}
|
||||
onChange={(e) =>
|
||||
setDisableCode(e.target.value.replace(/\D/g, ""))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleDisable}
|
||||
disabled={loading || (!password && !disableCode)}
|
||||
>
|
||||
{t("auth.disableTwoFactor")}
|
||||
</Button>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="backup" className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("auth.generateNewBackupCodesText")}
|
||||
</p>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="backup-password">
|
||||
{t("auth.passwordOrTotpCode")}
|
||||
</Label>
|
||||
<PasswordInput
|
||||
id="backup-password"
|
||||
placeholder={t("placeholders.enterPassword")}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">{t("auth.or")}</p>
|
||||
<Input
|
||||
id="backup-code"
|
||||
type="text"
|
||||
placeholder={t("placeholders.totpCode")}
|
||||
maxLength={6}
|
||||
value={disableCode}
|
||||
onChange={(e) =>
|
||||
setDisableCode(e.target.value.replace(/\D/g, ""))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handleGenerateNewBackupCodes}
|
||||
disabled={loading || (!password && !disableCode)}
|
||||
>
|
||||
{t("auth.generateNewBackupCodes")}
|
||||
</Button>
|
||||
|
||||
{backupCodes.length > 0 && (
|
||||
<div className="space-y-2 mt-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<Label>{t("auth.yourBackupCodes")}</Label>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={downloadBackupCodes}
|
||||
>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
{t("auth.download")}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2 p-4 bg-muted rounded-lg font-mono text-sm">
|
||||
{backupCodes.map((code, i) => (
|
||||
<div key={i}>{code}</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertTitle>{t("common.error")}</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (setupStep === "qr") {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("auth.setupTwoFactorTitle")}</CardTitle>
|
||||
<CardDescription>{t("auth.step1ScanQR")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex justify-center">
|
||||
<img src={qrCode} alt="TOTP QR Code" className="w-64 h-64" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>{t("auth.manualEntryCode")}</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input value={secret} readOnly className="font-mono text-sm" />
|
||||
<Button
|
||||
size="default"
|
||||
variant="outline"
|
||||
onClick={() => copyToClipboard(secret, "Secret key")}
|
||||
>
|
||||
<Copy className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("auth.cannotScanQRText")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button onClick={() => setSetupStep("verify")} className="w-full">
|
||||
{t("auth.nextVerifyCode")}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (setupStep === "verify") {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("auth.verifyAuthenticator")}</CardTitle>
|
||||
<CardDescription>{t("auth.step2EnterCode")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="verify-code">{t("auth.verificationCode")}</Label>
|
||||
<Input
|
||||
id="verify-code"
|
||||
type="text"
|
||||
placeholder="000000"
|
||||
maxLength={6}
|
||||
value={verificationCode}
|
||||
onChange={(e) =>
|
||||
setVerificationCode(e.target.value.replace(/\D/g, ""))
|
||||
}
|
||||
className="text-center text-2xl tracking-widest font-mono"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertTitle>{t("common.error")}</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setSetupStep("qr")}
|
||||
disabled={loading}
|
||||
>
|
||||
{t("auth.back")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleVerifyCode}
|
||||
disabled={loading || verificationCode.length !== 6}
|
||||
className="flex-1"
|
||||
>
|
||||
{loading ? t("interface.verifying") : t("auth.verifyAndEnable")}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (setupStep === "backup") {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("auth.saveBackupCodesTitle")}</CardTitle>
|
||||
<CardDescription>{t("auth.step3StoreCodesSecurely")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<Alert>
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertTitle>{t("common.important")}</AlertTitle>
|
||||
<AlertDescription>
|
||||
{t("auth.importantBackupCodesText")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<Label>Your Backup Codes</Label>
|
||||
<Button size="sm" variant="outline" onClick={downloadBackupCodes}>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
Download
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2 p-4 bg-muted rounded-lg font-mono text-sm">
|
||||
{backupCodes.map((code, i) => (
|
||||
<div key={i} className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground">{i + 1}.</span>
|
||||
<span>{code}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button onClick={handleComplete} className="w-full">
|
||||
{t("auth.completeSetup")}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Shield className="w-5 h-5" />
|
||||
{t("auth.twoFactorTitle")}
|
||||
</CardTitle>
|
||||
<CardDescription className="space-y-2">
|
||||
<p>{t("auth.addExtraSecurityLayer")}.</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 px-3 text-xs"
|
||||
onClick={() =>
|
||||
window.open("https://docs.termix.site/totp", "_blank")
|
||||
}
|
||||
>
|
||||
{t("common.documentation")}
|
||||
</Button>
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<Alert>
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertTitle>{t("common.notEnabled")}</AlertTitle>
|
||||
<AlertDescription>{t("auth.notEnabledText")}</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<Button
|
||||
onClick={handleSetupStart}
|
||||
disabled={loading}
|
||||
className="w-full h-11 text-base"
|
||||
>
|
||||
{loading ? t("common.settingUp") : t("auth.enableTwoFactorButton")}
|
||||
</Button>
|
||||
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user