mirror of
https://github.com/dyzulk/trustlab.git
synced 2026-01-26 13:32:06 +07:00
914 lines
42 KiB
TypeScript
914 lines
42 KiB
TypeScript
"use client";
|
|
|
|
import React, { useState } from "react";
|
|
import useSWR, { mutate } from "swr";
|
|
import axios from "@/lib/axios";
|
|
import { useToast } from "@/context/ToastContext";
|
|
import Button from "@/components/ui/button/Button";
|
|
import Input from "@/components/form/input/InputField";
|
|
import Label from "@/components/form/Label";
|
|
import { Modal } from "@/components/ui/modal";
|
|
import { useModal } from "@/hooks/useModal";
|
|
import Badge from "@/components/ui/badge/Badge";
|
|
import Link from "next/link";
|
|
import {
|
|
ShieldCheck,
|
|
History,
|
|
Link2,
|
|
AlertTriangle,
|
|
Mail,
|
|
Smartphone,
|
|
Globe,
|
|
Monitor,
|
|
Trash2,
|
|
Lock,
|
|
Bell,
|
|
Palette,
|
|
Send,
|
|
Languages,
|
|
Download,
|
|
Key,
|
|
ChevronRight,
|
|
LogOut,
|
|
DoorOpen,
|
|
Sun,
|
|
Moon,
|
|
Laptop
|
|
} from "lucide-react";
|
|
|
|
import { useTheme } from "@/context/ThemeContext";
|
|
|
|
import Switch from "@/components/form/switch/Switch";
|
|
import { useRouter, useSearchParams } from "next/navigation";
|
|
import PageLoader from "@/components/ui/PageLoader";
|
|
import { useTranslations } from "next-intl";
|
|
import { useI18n } from "@/components/providers/I18nProvider";
|
|
|
|
const fetcher = (url: string) => axios.get(url).then((res) => res.data);
|
|
|
|
export default function SettingsClient() {
|
|
const router = useRouter();
|
|
const searchParams = useSearchParams();
|
|
const { data: user, mutate: mutateUser } = useSWR("/api/user", fetcher);
|
|
const { data: loginHistory, isLoading: historyLoading } = useSWR("/api/profile/login-history", fetcher);
|
|
const { data: sessions, isLoading: sessionsLoading } = useSWR("/api/profile/sessions", fetcher);
|
|
const { data: apiKeys } = useSWR("/api/api-keys", fetcher);
|
|
|
|
const { addToast } = useToast();
|
|
const { theme: currentTheme, setTheme } = useTheme();
|
|
const { isOpen, openModal, closeModal } = useModal();
|
|
const { setLocale } = useI18n();
|
|
const t = useTranslations("Settings");
|
|
|
|
const [isSavingPassword, setIsSavingPassword] = useState(false);
|
|
const [isDeletingAccount, setIsDeletingAccount] = useState(false);
|
|
const [passwordData, setPasswordData] = useState({
|
|
current_password: "",
|
|
password: "",
|
|
password_confirmation: "",
|
|
});
|
|
|
|
// 2FA State
|
|
const [twoFactorMode, setTwoFactorMode] = useState<'enable' | 'disable' | 'recovery' | null>(null);
|
|
const [qrCode, setQrCode] = useState<string | null>(null);
|
|
const [setupSecret, setSetupSecret] = useState<string | null>(null);
|
|
const [verificationCode, setVerificationCode] = useState("");
|
|
const [recoveryCodes, setRecoveryCodes] = useState<string[]>([]);
|
|
const [confirmPassword, setConfirmPassword] = useState("");
|
|
const [isProcessing2FA, setIsProcessing2FA] = useState(false);
|
|
|
|
const enable2FA = async () => {
|
|
try {
|
|
setIsProcessing2FA(true);
|
|
const { data } = await axios.post('/api/auth/2fa/enable');
|
|
setQrCode(data.qr_code);
|
|
setSetupSecret(data.secret);
|
|
setTwoFactorMode('enable');
|
|
} catch (error) {
|
|
addToast(t("toast_2fa_setup_failed"), "error");
|
|
} finally {
|
|
setIsProcessing2FA(false);
|
|
}
|
|
};
|
|
|
|
const confirmEnable2FA = async () => {
|
|
try {
|
|
setIsProcessing2FA(true);
|
|
const { data } = await axios.post('/api/auth/2fa/confirm', { code: verificationCode });
|
|
addToast(t("toast_2fa_enabled"), "success");
|
|
setRecoveryCodes(data.recovery_codes);
|
|
setTwoFactorMode('recovery'); // Show recovery codes immediately
|
|
mutateUser();
|
|
} catch (error: any) {
|
|
addToast(error.response?.data?.message || t("toast_2fa_setup_failed"), "error");
|
|
} finally {
|
|
setIsProcessing2FA(false);
|
|
}
|
|
};
|
|
|
|
const disable2FA = async () => {
|
|
try {
|
|
setIsProcessing2FA(true);
|
|
await axios.delete('/api/auth/2fa/disable', { data: { password: confirmPassword } });
|
|
addToast(t("toast_2fa_disabled"), "success");
|
|
setTwoFactorMode(null);
|
|
setConfirmPassword("");
|
|
mutateUser();
|
|
} catch (error: any) {
|
|
addToast(error.response?.data?.message || t("toast_2fa_disable_failed"), "error");
|
|
} finally {
|
|
setIsProcessing2FA(false);
|
|
}
|
|
};
|
|
|
|
const showRecoveryCodes = async () => {
|
|
try {
|
|
const { data } = await axios.get('/api/auth/2fa/recovery-codes');
|
|
setRecoveryCodes(data.recovery_codes);
|
|
setTwoFactorMode('recovery');
|
|
} catch (error) {
|
|
addToast(t("toast_recovery_failed"), "error");
|
|
}
|
|
};
|
|
|
|
const close2FAModal = () => {
|
|
setTwoFactorMode(null);
|
|
setQrCode(null);
|
|
setVerificationCode("");
|
|
setRecoveryCodes([]);
|
|
setConfirmPassword("");
|
|
};
|
|
|
|
// Handle Success/Error Alerts from URL (e.g. from OAuth Callback)
|
|
React.useEffect(() => {
|
|
const success = searchParams.get("success");
|
|
const error = searchParams.get("error");
|
|
|
|
if (success) {
|
|
if (success === 'account_connected') addToast(t("toast_connected"), "success");
|
|
// Clear params
|
|
router.replace("/dashboard/settings");
|
|
}
|
|
|
|
if (error) {
|
|
if (error === 'already_connected') addToast(t("toast_already_connected"), "warning");
|
|
if (error === 'connected_to_other_account') addToast(t("toast_connected_other"), "error");
|
|
if (error === 'login_required_to_connect') addToast(t("toast_login_required"), "error");
|
|
// Clear params
|
|
router.replace("/dashboard/settings");
|
|
}
|
|
}, [searchParams, router, addToast, t]);
|
|
|
|
const connectAccount = async (provider: string) => {
|
|
try {
|
|
addToast(t("toast_connect_init"), "info");
|
|
// Get secure link token to identify user during redirect
|
|
const { data } = await axios.get('/api/auth/link-token');
|
|
// Redirect to backend auth endpoint with context and token
|
|
window.location.href = `${process.env.NEXT_PUBLIC_API_URL}/api/auth/${provider}/redirect?context=connect&link_token=${data.token}`;
|
|
} catch (error) {
|
|
addToast(t("toast_connect_failed"), "error");
|
|
}
|
|
};
|
|
|
|
const disconnectAccount = async (provider: string) => {
|
|
// confirm disconnect?
|
|
try {
|
|
addToast(t("toast_disconnect_init"), "info");
|
|
await axios.delete(`/api/auth/social/${provider}`);
|
|
addToast(t("toast_disconnected"), "success");
|
|
mutateUser(); // Refresh user state
|
|
} catch (error: any) {
|
|
addToast(error.response?.data?.message || t("toast_disconnect_failed"), "error");
|
|
}
|
|
};
|
|
|
|
const savePreference = async (key: string, value: any) => {
|
|
try {
|
|
// Optimistic update could go here, but for simplicity we rely on SWR revalidation or just wait
|
|
await axios.patch('/api/profile', {
|
|
[key]: value
|
|
});
|
|
|
|
if (key === 'theme') setTheme(value);
|
|
if (key === 'language') setLocale(value);
|
|
|
|
mutateUser(); // Refresh user data to confirm sync
|
|
addToast(t("toast_settings_updated"), "success");
|
|
} catch (err) {
|
|
addToast(t("toast_settings_failed"), "error");
|
|
}
|
|
};
|
|
|
|
const handlePasswordChange = (field: string, value: string) => {
|
|
setPasswordData((prev) => ({ ...prev, [field]: value }));
|
|
};
|
|
|
|
const updatePassword = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
if (passwordData.password !== passwordData.password_confirmation) {
|
|
addToast(t("toast_password_mismatch"), "error");
|
|
return;
|
|
}
|
|
|
|
setIsSavingPassword(true);
|
|
try {
|
|
await axios.put("/api/profile/password", passwordData);
|
|
addToast(t("toast_password_updated"), "success");
|
|
setPasswordData({
|
|
current_password: "",
|
|
password: "",
|
|
password_confirmation: "",
|
|
});
|
|
} catch (err: any) {
|
|
const message = err.response?.data?.message || t("toast_password_failed");
|
|
addToast(message, "error");
|
|
} finally {
|
|
setIsSavingPassword(false);
|
|
}
|
|
};
|
|
|
|
const revokeSession = async (id: string) => {
|
|
try {
|
|
await axios.delete(`/api/profile/sessions/${id}`);
|
|
mutate("/api/profile/sessions");
|
|
addToast(t("toast_session_revoked"), "success");
|
|
} catch (err) {
|
|
addToast(t("toast_revoke_failed"), "error");
|
|
}
|
|
};
|
|
|
|
const handleDeleteAccount = async () => {
|
|
setIsDeletingAccount(true);
|
|
try {
|
|
await axios.delete("/api/profile");
|
|
addToast(t("toast_account_deleted"), "success");
|
|
setTimeout(() => {
|
|
window.location.href = "/signin";
|
|
}, 2000);
|
|
} catch (err) {
|
|
addToast(t("toast_delete_failed"), "error");
|
|
} finally {
|
|
setIsDeletingAccount(false);
|
|
closeModal();
|
|
}
|
|
};
|
|
|
|
const getDeviceIcon = (deviceType: string) => {
|
|
switch (deviceType?.toLowerCase()) {
|
|
case 'ios':
|
|
case 'android':
|
|
return <Smartphone className="w-5 h-5 text-gray-500" />;
|
|
case 'mac':
|
|
case 'windows':
|
|
case 'linux':
|
|
return <Monitor className="w-5 h-5 text-gray-500" />;
|
|
default:
|
|
return <Globe className="w-5 h-5 text-gray-500" />;
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* Account Verification & Summary */}
|
|
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
|
<div className="p-5 bg-white border border-gray-200 rounded-2xl dark:border-gray-800 dark:bg-white/[0.03] lg:p-6">
|
|
<div className="flex items-center gap-4">
|
|
<div className="flex items-center justify-center w-12 h-12 rounded-full bg-blue-50 dark:bg-blue-500/10">
|
|
<Mail className="w-6 h-6 text-blue-600 dark:text-blue-400" />
|
|
</div>
|
|
<div>
|
|
<h4 className="text-lg font-semibold text-gray-800 dark:text-white/90">
|
|
{t("email_verification")}
|
|
</h4>
|
|
<div className="flex flex-wrap items-center gap-2 mt-1">
|
|
{user?.email_verified_at ? (
|
|
<Badge color="success" size="sm" variant="light" startIcon={<ShieldCheck className="w-3.5 h-3.5" />}>
|
|
{t("verified")}
|
|
</Badge>
|
|
) : (
|
|
<Badge color="warning" size="sm" variant="light">
|
|
{t("not_verified")}
|
|
</Badge>
|
|
)}
|
|
<span className="text-sm text-gray-500 dark:text-gray-400 font-medium">
|
|
{user?.email}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="p-5 bg-white border border-gray-200 rounded-2xl dark:border-gray-800 dark:bg-white/[0.03] lg:p-6">
|
|
<div className="flex items-center gap-4">
|
|
<div className="flex items-center justify-center w-12 h-12 rounded-full bg-purple-50 dark:bg-purple-500/10">
|
|
<Key className="w-6 h-6 text-purple-600 dark:text-purple-400" />
|
|
</div>
|
|
<div>
|
|
<h4 className="text-lg font-semibold text-gray-800 dark:text-white/90">
|
|
{t("api_keys_summary")}
|
|
</h4>
|
|
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
|
{t("api_keys_desc", { count: apiKeys?.data?.filter((k: any) => k.is_active).length || 0 })}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Security: Update Password */}
|
|
<div className="p-5 bg-white border border-gray-200 rounded-2xl dark:border-gray-800 dark:bg-white/[0.03] lg:p-6">
|
|
<h4 className="mb-6 text-lg font-semibold text-gray-800 dark:text-white/90">
|
|
{t("security_password")}
|
|
</h4>
|
|
<form onSubmit={updatePassword} className="space-y-5">
|
|
<div className="grid grid-cols-1 gap-5 lg:grid-cols-2 lg:gap-6">
|
|
<div className="col-span-1 lg:col-span-2">
|
|
<Label>{t("current_password")}</Label>
|
|
<Input
|
|
type="password"
|
|
value={passwordData.current_password}
|
|
onChange={(e) => handlePasswordChange("current_password", e.target.value)}
|
|
placeholder={t("current_password")}
|
|
required
|
|
/>
|
|
</div>
|
|
<div>
|
|
<Label>{t("new_password")}</Label>
|
|
<Input
|
|
type="password"
|
|
value={passwordData.password}
|
|
onChange={(e) => handlePasswordChange("password", e.target.value)}
|
|
placeholder={t("new_password")}
|
|
required
|
|
/>
|
|
</div>
|
|
<div>
|
|
<Label>{t("confirm_new_password")}</Label>
|
|
<Input
|
|
type="password"
|
|
value={passwordData.password_confirmation}
|
|
onChange={(e) => handlePasswordChange("password_confirmation", e.target.value)}
|
|
placeholder={t("confirm_new_password")}
|
|
required
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className="flex justify-end">
|
|
<Button type="submit" loading={isSavingPassword}>
|
|
{t("update_password")}
|
|
</Button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
|
|
{/* Active Sessions Management */}
|
|
<div className="p-5 bg-white border border-gray-200 rounded-2xl dark:border-gray-800 dark:bg-white/[0.03] lg:p-6">
|
|
<h4 className="mb-6 text-lg font-semibold text-gray-800 dark:text-white/90">
|
|
{t("active_sessions")}
|
|
</h4>
|
|
{sessionsLoading ? (
|
|
<PageLoader text={t("loading_sessions")} className="py-10" />
|
|
) : sessions?.length > 0 ? (
|
|
<div className="space-y-4">
|
|
{sessions.map((session: any) => (
|
|
<div key={session.id} className="flex items-center justify-between p-4 border border-gray-100 rounded-xl dark:border-gray-800">
|
|
<div className="flex items-center gap-3">
|
|
<div className="p-2 bg-gray-100 rounded-lg dark:bg-gray-800">
|
|
<Monitor className="w-5 h-5 text-gray-500" />
|
|
</div>
|
|
<div>
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<p className="text-sm font-medium text-gray-800 dark:text-white/90">
|
|
{session.name || "Default Session"}
|
|
</p>
|
|
{session.is_current && (
|
|
<Badge color="success" size="sm" variant="light">
|
|
{t("session_current")}
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
<p className="text-xs text-gray-500 dark:text-gray-400">
|
|
Last active: {new Date(session.last_active * 1000).toLocaleString()}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
{!session.is_current && (
|
|
<button
|
|
onClick={() => revokeSession(session.id)}
|
|
className="p-2 text-red-500 transition-colors rounded-lg hover:bg-red-50 dark:hover:bg-red-500/10"
|
|
title={t("logout_device")}
|
|
>
|
|
<LogOut className="w-5 h-5" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<div className="py-10 text-center text-gray-500 border-2 border-dashed border-gray-100 rounded-xl dark:border-gray-800">
|
|
{t("no_sessions")}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Login History (Last 30 Days) */}
|
|
<div className="p-5 bg-white border border-gray-200 rounded-2xl dark:border-gray-800 dark:bg-white/[0.03] lg:p-6">
|
|
<div className="flex flex-wrap items-center justify-between gap-3 mb-6">
|
|
<h4 className="text-lg font-semibold text-gray-800 dark:text-white/90">
|
|
{t("login_activity")}
|
|
</h4>
|
|
<Badge color="light" size="sm" variant="light">
|
|
{t("last_month")}
|
|
</Badge>
|
|
</div>
|
|
|
|
{historyLoading ? (
|
|
<PageLoader text="Loading activity..." className="py-10" />
|
|
) : loginHistory?.length > 0 ? (
|
|
<div className="overflow-x-auto border border-gray-100 rounded-xl dark:border-gray-800">
|
|
<table className="min-w-[600px] w-full text-left">
|
|
<thead>
|
|
<tr className="bg-gray-50 dark:bg-white/[0.02]">
|
|
<th className="px-5 py-4 text-xs font-semibold text-gray-500 uppercase dark:text-gray-400 w-[30%]">{t("browser_os")}</th>
|
|
<th className="px-5 py-4 text-xs font-semibold text-gray-500 uppercase dark:text-gray-400 w-[20%]">{t("ip_address")}</th>
|
|
<th className="px-5 py-4 text-xs font-semibold text-gray-500 uppercase dark:text-gray-400 w-[25%]">{t("location")}</th>
|
|
<th className="px-5 py-4 text-xs font-semibold text-gray-500 uppercase dark:text-gray-400 w-[25%] text-right">{t("time")}</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-gray-100 dark:divide-gray-800">
|
|
{loginHistory.map((item: any) => (
|
|
<tr key={item.id} className="hover:bg-gray-50/50 dark:hover:bg-white/[0.01]">
|
|
<td className="px-5 py-4">
|
|
<div className="flex items-center gap-3">
|
|
{getDeviceIcon(item.device_type)}
|
|
<div>
|
|
<p className="text-sm font-medium text-gray-800 dark:text-white/90">{item.browser}</p>
|
|
<p className="text-xs text-gray-500 dark:text-gray-400">{item.os}</p>
|
|
</div>
|
|
</div>
|
|
</td>
|
|
<td className="px-5 py-4">
|
|
<span className="text-sm text-gray-600 dark:text-gray-400 font-mono">{item.ip_address}</span>
|
|
</td>
|
|
<td className="px-5 py-4">
|
|
<div className="flex items-center gap-2">
|
|
{item.country_code && (
|
|
<div className="flex-shrink-0 w-5 h-3.5 overflow-hidden rounded-[2px] shadow-sm border border-gray-100 dark:border-gray-800">
|
|
<img
|
|
src={`https://flagcdn.com/${item.country_code.toLowerCase()}.svg`}
|
|
alt={item.country}
|
|
className="block w-full h-full object-cover"
|
|
/>
|
|
</div>
|
|
)}
|
|
<p className="text-sm text-gray-600 dark:text-gray-400">
|
|
{item.city && item.country ? `${item.city}, ${item.country}` : "Unknown"}
|
|
</p>
|
|
</div>
|
|
</td>
|
|
<td className="px-5 py-4 text-right whitespace-nowrap">
|
|
<p className="text-sm text-gray-600 dark:text-gray-400">
|
|
{new Date(item.created_at).toLocaleString()}
|
|
</p>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
) : (
|
|
<div className="py-10 text-center text-gray-500 border-2 border-dashed border-gray-100 rounded-xl dark:border-gray-800">
|
|
{t("no_activity")}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Advanced Security & UI Placeholders */}
|
|
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
|
{/* 2FA Section */}
|
|
<div className="p-5 bg-white border border-gray-200 rounded-2xl dark:border-gray-800 dark:bg-white/[0.03] lg:p-6">
|
|
<div className="flex flex-wrap items-center justify-between gap-3 mb-4">
|
|
<div className="flex items-center gap-3 text-gray-500 dark:text-gray-400">
|
|
<Lock className="w-5 h-5" />
|
|
<h4 className="text-lg font-semibold text-gray-800 dark:text-white/90">{t("2fa_title")}</h4>
|
|
</div>
|
|
{user?.two_factor_confirmed_at ? (
|
|
<Badge color="success" size="sm" variant="light">Enabled</Badge>
|
|
) : (
|
|
<Badge color="warning" size="sm" variant="light">Disabled</Badge>
|
|
)}
|
|
</div>
|
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
|
{t("2fa_desc")}
|
|
</p>
|
|
<div className="mt-4">
|
|
{user?.two_factor_confirmed_at ? (
|
|
<div className="flex gap-3">
|
|
<Button variant="danger" size="sm" className="w-full" onClick={() => setTwoFactorMode('disable')}>
|
|
{t("disable_2fa")}
|
|
</Button>
|
|
<Button variant="outline" size="sm" className="w-full" onClick={showRecoveryCodes}>
|
|
{t("view_recovery_codes")}
|
|
</Button>
|
|
</div>
|
|
) : (
|
|
<Button variant="outline" size="sm" className="w-full" onClick={enable2FA}>
|
|
{t("setup_2fa")}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Notifications Placeholder */}
|
|
<div className="p-5 bg-white border border-gray-200 rounded-2xl dark:border-gray-800 dark:bg-white/[0.03] lg:p-6">
|
|
<div className="flex flex-wrap items-center justify-between gap-3 mb-4">
|
|
<div className="flex items-center gap-3 text-gray-500 dark:text-gray-400">
|
|
<Bell className="w-5 h-5" />
|
|
<h4 className="text-lg font-semibold text-gray-800 dark:text-white/90">{t("notifications")}</h4>
|
|
</div>
|
|
{/* Badge Removed */}
|
|
</div>
|
|
<div className="space-y-4">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<span className="block text-sm font-medium text-gray-700 dark:text-gray-300">{t("email_alerts")}</span>
|
|
<span className="block text-xs text-gray-500 dark:text-gray-400 mt-0.5">{t("email_alerts_desc")}</span>
|
|
</div>
|
|
<Switch
|
|
label=""
|
|
defaultChecked={!!user?.settings_email_alerts}
|
|
onChange={(checked) => savePreference('settings_email_alerts', checked)}
|
|
disabled={!user}
|
|
/>
|
|
</div>
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<span className="block text-sm font-medium text-gray-700 dark:text-gray-300">{t("cert_renewal")}</span>
|
|
<span className="block text-xs text-gray-500 dark:text-gray-400 mt-0.5">{t("cert_renewal_desc")}</span>
|
|
</div>
|
|
<Switch
|
|
label=""
|
|
defaultChecked={!!user?.settings_certificate_renewal}
|
|
onChange={(checked) => savePreference('settings_certificate_renewal', checked)}
|
|
disabled={!user}
|
|
/>
|
|
</div>
|
|
</div>
|
|
{(user?.role === 'admin' || user?.role === 'owner') && (
|
|
<div className="mt-5 pt-5 border-t border-gray-100 dark:border-gray-800">
|
|
<Link href="/dashboard/admin/smtp-tester">
|
|
<Button variant="outline" size="sm" className="w-full flex items-center justify-center gap-2">
|
|
<Send className="w-4 h-4" /> {t("smtp_tester")}
|
|
</Button>
|
|
</Link>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Appearance Settings */}
|
|
<div className="p-5 bg-white border border-gray-200 rounded-2xl dark:border-gray-800 dark:bg-white/[0.03] lg:p-6">
|
|
<div className="flex items-center justify-between mb-6">
|
|
<div className="flex items-center gap-3 text-gray-500 dark:text-gray-400">
|
|
<Palette className="w-5 h-5" />
|
|
<h4 className="text-lg font-semibold text-gray-800 dark:text-white/90">{t("appearance")}</h4>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-6">
|
|
<div>
|
|
<Label className="mb-3 block text-sm font-medium">{t('theme')}</Label>
|
|
<div className="grid grid-cols-3 gap-3">
|
|
{[
|
|
{ id: 'light', label: t('light'), icon: <Sun className="w-4 h-4" /> },
|
|
{ id: 'dark', label: t('dark'), icon: <Moon className="w-4 h-4" /> },
|
|
{ id: 'system', label: t('system'), icon: <Laptop className="w-4 h-4" /> },
|
|
].map((t) => (
|
|
<button
|
|
key={t.id}
|
|
onClick={() => savePreference('theme', t.id as any)}
|
|
className={`flex flex-col items-center justify-center p-3 border rounded-xl transition-all gap-2 ${
|
|
user?.theme === t.id
|
|
? 'border-brand-500 bg-brand-50/50 dark:bg-brand-500/10 text-brand-600 dark:text-brand-400'
|
|
: 'border-gray-100 dark:border-gray-800 text-gray-500 dark:text-gray-400 hover:border-brand-200'
|
|
}`}
|
|
>
|
|
{t.icon}
|
|
<span className="text-xs font-medium">{t.label}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<Label className="mb-3 block text-sm font-medium">{t('language')}</Label>
|
|
<div className="relative">
|
|
<select
|
|
value={user?.language || 'en'}
|
|
onChange={(e) => savePreference('language', e.target.value)}
|
|
className="w-full bg-white dark:bg-gray-900 border border-gray-100 dark:border-gray-800 rounded-xl px-4 py-3 text-sm text-gray-700 dark:text-gray-300 focus:border-brand-500 outline-none appearance-none transition-all cursor-pointer"
|
|
>
|
|
<option value="en" className="bg-white dark:bg-gray-900">{t('english')}</option>
|
|
<option value="id" className="bg-white dark:bg-gray-900">{t('indonesian')}</option>
|
|
</select>
|
|
<div className="absolute right-4 top-1/2 -translate-y-1/2 pointer-events-none text-gray-400">
|
|
<ChevronRight className="w-4 h-4 rotate-90" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Export & Data Placeholder */}
|
|
<div className="p-5 bg-white border border-gray-200 rounded-2xl dark:border-gray-800 dark:bg-white/[0.03] lg:p-6">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<div className="flex items-center gap-3 text-gray-500 dark:text-gray-400">
|
|
<Download className="w-5 h-5" />
|
|
<h4 className="text-lg font-semibold text-gray-800 dark:text-white/90">{t("privacy_data")}</h4>
|
|
</div>
|
|
</div>
|
|
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">{t("privacy_desc")}</p>
|
|
<Button variant="outline" size="sm" disabled className="w-full flex items-center justify-center gap-2">
|
|
<Download className="w-4 h-4" /> {t("export_data")}
|
|
</Button>
|
|
</div>
|
|
|
|
{/* Landing Page Selection */}
|
|
<div className="p-5 bg-white border border-gray-200 rounded-2xl dark:border-gray-800 dark:bg-white/[0.03] lg:p-6 lg:col-span-2">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<div className="flex items-center gap-3 text-gray-500 dark:text-gray-400">
|
|
<DoorOpen className="w-5 h-5" />
|
|
<h4 className="text-lg font-semibold text-gray-800 dark:text-white/90">{t("landing_page")}</h4>
|
|
</div>
|
|
</div>
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-4">
|
|
{[
|
|
{ id: '/dashboard', label: t('landing_page_dashboard'), icon: <Monitor className="w-4 h-4" /> },
|
|
{ id: '/dashboard/support', label: t('landing_page_support'), icon: <Smartphone className="w-4 h-4" /> },
|
|
{ id: '/dashboard/certificates', label: t('landing_page_certs'), icon: <ShieldCheck className="w-4 h-4" /> },
|
|
{ id: '/dashboard/api-keys', label: t('landing_page_keys'), icon: <Key className="w-4 h-4" /> },
|
|
].map((option) => (
|
|
<button
|
|
key={option.id}
|
|
onClick={() => savePreference('default_landing_page', option.id)}
|
|
className={`flex items-center justify-between p-4 border rounded-xl transition-all ${
|
|
user?.default_landing_page === option.id
|
|
? 'border-brand-500 bg-brand-50/50 dark:bg-brand-500/10 text-brand-600 dark:text-brand-400 font-bold'
|
|
: 'border-gray-100 dark:border-gray-800 text-gray-500 dark:text-gray-400 hover:border-brand-200'
|
|
}`}
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
{option.icon}
|
|
<span className="text-sm">{option.label}</span>
|
|
</div>
|
|
{user?.default_landing_page === option.id && (
|
|
<div className="w-2 h-2 rounded-full bg-brand-500" />
|
|
)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Linked Accounts */}
|
|
<div className="p-5 bg-white border border-gray-200 rounded-2xl dark:border-gray-800 dark:bg-white/[0.03] lg:p-6">
|
|
<h4 className="mb-6 text-lg font-semibold text-gray-800 dark:text-white/90">
|
|
{t("linked_accounts")}
|
|
</h4>
|
|
<div className="space-y-4">
|
|
<div className="flex items-center justify-between p-4 border border-gray-100 rounded-xl dark:border-gray-800">
|
|
<div className="flex items-center gap-3">
|
|
<div className="flex items-center justify-center w-10 h-10 border border-gray-100 rounded-full dark:border-gray-800">
|
|
<svg width="20" height="20" viewBox="0 0 48 48">
|
|
<path fill="#EA4335" d="M24 9.5c3.54 0 6.71 1.22 9.21 3.6l6.85-6.85C35.9 2.38 30.47 0 24 0 14.62 0 6.51 5.38 2.56 13.22l7.98 6.19C12.43 13.72 17.74 9.5 24 9.5z"/>
|
|
<path fill="#4285F4" d="M46.98 24.55c0-1.57-.15-3.09-.38-4.55H24v9.02h12.94c-.58 2.96-2.26 5.48-4.78 7.18l7.73 6c4.51-4.18 7.09-10.36 7.09-17.65z"/>
|
|
<path fill="#FBBC05" d="M10.53 28.59c-.48-1.45-.76-2.99-.76-4.59s.27-3.14.76-4.59l-7.98-6.19C.92 16.46 0 20.12 0 24s.92 7.54 2.56 10.78l7.97-6.19z"/>
|
|
<path fill="#34A853" d="M24 48c6.48 0 11.93-2.13 15.89-5.81l-7.73-6c-2.15 1.45-4.92 2.3-8.16 2.3-6.26 0-11.57-4.22-13.47-9.91l-7.98 6.19C6.51 42.62 14.62 48 24 48z"/>
|
|
<path fill="none" d="M0 0h48v48H0z"/>
|
|
</svg>
|
|
</div>
|
|
<div>
|
|
<p className="text-sm font-medium text-gray-800 dark:text-white/90">{t("google_account")}</p>
|
|
<div className="mt-1">
|
|
{user?.social_accounts?.some((a: any) => a.provider === 'google') ? (
|
|
<Badge color="success" size="sm" variant="light">{t("connected")}</Badge>
|
|
) : (
|
|
<Badge color="light" size="sm" variant="light">{t("not_connected")}</Badge>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{user?.social_accounts?.some((a: any) => a.provider === 'google') ? (
|
|
<Button variant="outline" size="sm" onClick={() => disconnectAccount('google')}>
|
|
{t("disconnect")}
|
|
</Button>
|
|
) : (
|
|
<Button variant="outline" size="sm" onClick={() => connectAccount('google')}>
|
|
{t("connect")}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex items-center justify-between p-4 border border-gray-100 rounded-xl dark:border-gray-800">
|
|
<div className="flex items-center gap-3">
|
|
<div className="flex items-center justify-center w-10 h-10 border border-gray-100 rounded-full dark:border-gray-800">
|
|
<svg width="20" height="20" viewBox="0 0 24 24" className="text-gray-800 dark:text-white" fill="currentColor">
|
|
<path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"/>
|
|
</svg>
|
|
</div>
|
|
<div>
|
|
<p className="text-sm font-medium text-gray-800 dark:text-white/90">{t("github_account")}</p>
|
|
<div className="mt-1">
|
|
{user?.social_accounts?.some((a: any) => a.provider === 'github') ? (
|
|
<Badge color="success" size="sm" variant="light">{t("connected")}</Badge>
|
|
) : (
|
|
<Badge color="light" size="sm" variant="light">{t("not_connected")}</Badge>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{user?.social_accounts?.some((a: any) => a.provider === 'github') ? (
|
|
<Button variant="outline" size="sm" onClick={() => disconnectAccount('github')}>
|
|
{t("disconnect")}
|
|
</Button>
|
|
) : (
|
|
<Button variant="outline" size="sm" onClick={() => connectAccount('github')}>
|
|
{t("connect")}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Danger Zone */}
|
|
<div className="p-5 border border-red-200 bg-red-50 rounded-2xl dark:border-red-500/15 dark:bg-red-500/5 lg:p-6">
|
|
<div className="flex items-start gap-4">
|
|
<div className="flex items-center justify-center w-12 h-12 bg-white rounded-full shadow-sm dark:bg-red-500/10">
|
|
<AlertTriangle className="w-6 h-6 text-red-600 dark:text-red-500" />
|
|
</div>
|
|
<div className="flex-1">
|
|
<h4 className="text-lg font-semibold text-gray-800 dark:text-white/90">
|
|
{t("danger_zone")}
|
|
</h4>
|
|
<p className="mt-1 text-sm text-gray-600 dark:text-gray-400">
|
|
{t("delete_account_desc")}
|
|
</p>
|
|
<div className="mt-6">
|
|
<Button variant="danger" onClick={openModal}>
|
|
{t("delete_account_button")}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Delete Confirmation Modal */}
|
|
<Modal isOpen={isOpen} onClose={closeModal} className="max-w-[450px] m-4">
|
|
<div className="p-6 sm:p-8">
|
|
<div className="flex flex-col items-center text-center">
|
|
<div className="flex items-center justify-center w-16 h-16 mb-4 bg-red-50 rounded-full dark:bg-red-500/10">
|
|
<Trash2 className="w-8 h-8 text-red-600 dark:text-red-500" />
|
|
</div>
|
|
<h3 className="mb-2 text-xl font-bold text-gray-800 dark:text-white/90">{t("delete_modal_title")}</h3>
|
|
<p className="mb-8 text-sm text-gray-600 dark:text-gray-400">
|
|
{t("delete_modal_desc")}
|
|
</p>
|
|
<div className="flex flex-col w-full gap-3 sm:flex-row sm:justify-center">
|
|
<Button variant="outline" className="w-full sm:w-auto" onClick={closeModal} disabled={isDeletingAccount}>
|
|
{t("delete_modal_cancel")}
|
|
</Button>
|
|
<Button variant="danger" className="w-full sm:w-auto" onClick={handleDeleteAccount} loading={isDeletingAccount}>
|
|
{t("delete_modal_confirm")}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Modal>
|
|
|
|
{/* 2FA Modals */}
|
|
<Modal isOpen={twoFactorMode !== null} onClose={close2FAModal} className="max-w-[450px] m-4">
|
|
<div className="p-6 sm:p-8">
|
|
{twoFactorMode === 'enable' && (
|
|
<div className="flex flex-col items-center text-center">
|
|
<div className="flex items-center justify-center w-12 h-12 mb-4 bg-blue-50 rounded-full dark:bg-blue-500/10">
|
|
<Smartphone className="w-6 h-6 text-blue-600 dark:text-blue-400" />
|
|
</div>
|
|
<h3 className="mb-2 text-xl font-bold text-gray-800 dark:text-white/90">{t("setup_2fa_modal_title")}</h3>
|
|
<p className="mb-6 text-sm text-gray-600 dark:text-gray-400">
|
|
{t("setup_2fa_modal_desc")}
|
|
</p>
|
|
|
|
{!qrCode ? (
|
|
<div className="py-8">
|
|
<PageLoader text={t("loading_sessions")} />
|
|
</div>
|
|
) : (
|
|
<div className="w-full space-y-6">
|
|
<div className="p-4 bg-white border border-gray-100 rounded-xl dark:bg-white/5 dark:border-gray-800">
|
|
<p className="mb-3 text-xs font-bold text-gray-400 uppercase tracking-wider">{t("2fa_step_1")}</p>
|
|
<div className="flex justify-center mb-4 bg-white p-2 rounded-lg inline-block mx-auto">
|
|
<div dangerouslySetInnerHTML={{ __html: qrCode }} />
|
|
</div>
|
|
<p className="text-xs text-gray-500 dark:text-gray-400">
|
|
{t("2fa_step_1_desc")}
|
|
</p>
|
|
</div>
|
|
|
|
<div className="p-4 bg-gray-50 border border-gray-100 rounded-xl dark:bg-white/5 dark:border-gray-800 text-left">
|
|
<p className="mb-2 text-xs font-bold text-gray-400 uppercase tracking-wider">{t("2fa_step_2")}</p>
|
|
<div className="flex gap-2">
|
|
<input
|
|
type="text"
|
|
placeholder={t("code_placeholder")}
|
|
className="flex-1 px-4 py-2 bg-white dark:bg-black/20 border border-gray-200 dark:border-gray-700 rounded-lg focus:ring-2 focus:ring-brand-500 focus:border-transparent outline-none transition-all font-mono text-center tracking-widest text-lg"
|
|
value={verificationCode}
|
|
onChange={(e) => setVerificationCode(e.target.value.replace(/[^0-9]/g, '').slice(0, 6))}
|
|
maxLength={6}
|
|
/>
|
|
<Button
|
|
onClick={confirmEnable2FA}
|
|
loading={isProcessing2FA}
|
|
disabled={verificationCode.length !== 6}
|
|
>
|
|
{t("verify_enable")}
|
|
</Button>
|
|
</div>
|
|
<p className="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
|
{t("2fa_step_2_desc")}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{twoFactorMode === 'recovery' && (
|
|
<div className="flex flex-col items-center text-center">
|
|
<div className="flex items-center justify-center w-12 h-12 mb-4 bg-green-50 rounded-full dark:bg-green-500/10">
|
|
<Lock className="w-6 h-6 text-green-600 dark:text-green-400" />
|
|
</div>
|
|
<h3 className="mb-2 text-xl font-bold text-gray-800 dark:text-white/90">{t("recovery_codes_title")}</h3>
|
|
<p className="mb-6 text-sm text-gray-600 dark:text-gray-400">
|
|
{t("recovery_codes_desc")}
|
|
</p>
|
|
|
|
<div className="w-full bg-gray-50 dark:bg-black/20 border border-gray-200 dark:border-gray-800 rounded-xl p-4 mb-6">
|
|
<div className="grid grid-cols-2 gap-3 text-sm font-mono font-medium text-gray-800 dark:text-gray-200">
|
|
{recoveryCodes.map((code, idx) => (
|
|
<div key={idx} className="bg-white dark:bg-white/5 px-2 py-1 rounded border border-gray-100 dark:border-gray-700/50">
|
|
{code}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex w-full gap-3">
|
|
<Button variant="outline" className="flex-1" onClick={() => {
|
|
navigator.clipboard.writeText(recoveryCodes.join("\n"));
|
|
addToast("Codes copied to clipboard", "success");
|
|
}}>
|
|
{t("copy_codes")}
|
|
</Button>
|
|
<Button className="flex-1" onClick={close2FAModal}>
|
|
{t("done")}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{twoFactorMode === 'disable' && (
|
|
<div className="flex flex-col items-center text-center">
|
|
<div className="flex items-center justify-center w-16 h-16 mb-4 bg-red-50 rounded-full dark:bg-red-500/10">
|
|
<Lock className="w-8 h-8 text-red-600 dark:text-red-500" />
|
|
</div>
|
|
<h3 className="mb-2 text-xl font-bold text-gray-800 dark:text-white/90">{t("disable_2fa")}?</h3>
|
|
<p className="mb-6 text-sm text-gray-600 dark:text-gray-400">
|
|
{t("disable_2fa_warning")}
|
|
</p>
|
|
|
|
<div className="w-full mb-6 text-left">
|
|
<Label className="mb-2 block">{t("current_password")}</Label>
|
|
<Input
|
|
type="password"
|
|
value={confirmPassword}
|
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
|
placeholder={t("current_password")}
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex flex-col w-full gap-3 sm:flex-row sm:justify-center">
|
|
<Button variant="outline" className="w-full sm:w-auto" onClick={close2FAModal}>
|
|
{t("cancel")}
|
|
</Button>
|
|
<Button variant="danger" className="w-full sm:w-auto" onClick={disable2FA} loading={isProcessing2FA} disabled={!confirmPassword}>
|
|
{t("confirm_disable_2fa")}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</Modal>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|