"use client";
import {
AlertTriangle,
Building,
ChevronDown,
Database,
Download,
Eye,
EyeOff,
FileText,
FileUp,
Info,
Key,
Monitor,
Palette,
Shield,
Upload,
User,
Users,
Link as LinkIcon,
} from "lucide-react";
import dynamic from "next/dynamic";
import { authClient } from "~/lib/auth-client";
import { useAuthSession } from "~/hooks/use-auth-session";
import * as React from "react";
import { useState } from "react";
import Link from "next/link";
import { toast } from "sonner";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "~/components/ui/alert-dialog";
import { Button } from "~/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "~/components/ui/card";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "~/components/ui/collapsible";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "~/components/ui/dialog";
import { Input } from "~/components/ui/input";
import { InputColor } from "~/components/ui/input-color";
import { Label } from "~/components/ui/label";
import { Textarea } from "~/components/ui/textarea";
import { api } from "~/trpc/react";
import { env } from "~/env";
import { Switch } from "~/components/ui/switch";
import { Slider } from "~/components/ui/slider";
import { useAnimationPreferences } from "~/components/providers/animation-preferences-provider";
import {
PageTabs,
PageTabsContent,
PageTabsList,
PageTabsTrigger,
pageTabsGridClass,
} from "~/components/layout/page-tabs";
import { cn } from "~/lib/utils";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "~/components/ui/select";
import { useAppearance } from "~/components/providers/appearance-provider";
import { brand, colorModes } from "~/lib/branding";
import type { PdfTemplate } from "~/lib/appearance";
import { ApiAccessSettings } from "./api-access-settings";
const PdfPreviewFrame = dynamic(
() => import("./pdf-preview-frame").then((module) => module.PdfPreviewFrame),
{
ssr: false,
loading: () => (
Loading PDF preview...
),
},
);
function isFullHexColor(value: string) {
return /^#[0-9A-Fa-f]{6}$/.test(value);
}
export function SettingsContent() {
const { data: session } = useAuthSession();
const [name, setName] = useState("");
const [deleteConfirmText, setDeleteConfirmText] = useState("");
const [importData, setImportData] = useState("");
const [isImportDialogOpen, setIsImportDialogOpen] = useState(false);
const [importMethod, setImportMethod] = useState<"file" | "paste">("file");
// Password change state
const [currentPassword, setCurrentPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [showCurrentPassword, setShowCurrentPassword] = useState(false);
const [showNewPassword, setShowNewPassword] = useState(false);
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
const [isLinking, setIsLinking] = useState(false);
const authentikEnabled = env.NEXT_PUBLIC_AUTHENTIK_ENABLED === true;
const { colorMode, updateAppearance, isUpdating: appearanceUpdating } =
useAppearance();
const utils = api.useUtils();
const { data: pdfSettings } = api.settings.getPdfSettings.useQuery();
const updatePdfSettingsMutation = api.settings.updatePdfSettings.useMutation({
onSuccess: async () => {
await utils.settings.getPdfSettings.invalidate();
toast.success("Invoice PDF settings updated");
},
onError: (error: { message: string }) => {
toast.error(`Failed to update PDF settings: ${error.message}`);
},
});
const savePdfSettings = (patch: {
pdfTemplate?: PdfTemplate;
pdfAccentColor?: string;
pdfFooterText?: string;
pdfShowLogo?: boolean;
pdfShowPageNumbers?: boolean;
}) => {
updatePdfSettingsMutation.mutate(patch);
};
const handleLinkAuthentik = async () => {
setIsLinking(true);
try {
await authClient.signIn.oauth2({
providerId: "authentik",
callbackURL: "/dashboard/settings",
});
} catch {
toast.error("Failed to link account");
setIsLinking(false);
}
};
// Animation preferences via provider (centralized)
const {
prefersReducedMotion,
animationSpeedMultiplier,
updatePreferences,
isUpdating: animationPrefsUpdating,
setPrefersReducedMotion,
setAnimationSpeedMultiplier,
} = useAnimationPreferences();
const handleSaveAnimationPreferences = (e: React.FormEvent) => {
e.preventDefault();
updatePreferences({
prefersReducedMotion,
animationSpeedMultiplier,
});
toast.success("Animation preferences updated");
};
// Queries
const { data: profile, refetch: refetchProfile } =
api.settings.getProfile.useQuery();
const isAdmin = profile?.role === "admin";
const { data: dataStats } = api.settings.getDataStats.useQuery();
// Mutations
const updateProfileMutation = api.settings.updateProfile.useMutation({
onSuccess: () => {
toast.success("Profile updated successfully");
void refetchProfile();
},
onError: (error: { message: string }) => {
toast.error(`Failed to update profile: ${error.message}`);
},
});
const changePasswordMutation = api.settings.changePassword.useMutation({
onSuccess: () => {
toast.success("Password changed successfully");
setCurrentPassword("");
setNewPassword("");
setConfirmPassword("");
},
onError: (error: { message: string }) => {
toast.error(`Failed to change password: ${error.message}`);
},
});
const exportDataQuery = api.settings.exportData.useQuery(undefined, {
enabled: false,
});
// Handle download logic
const handleDownload = React.useCallback((data: unknown) => {
const blob = new Blob([JSON.stringify(data, null, 2)], {
type: "application/json",
});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `beenvoice-backup-${new Date().toISOString().split("T")[0]}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
toast.success("Data backup downloaded successfully");
}, []);
const importDataMutation = api.settings.importData.useMutation({
onSuccess: (result) => {
const { imported } = result;
toast.success(
`Data imported successfully! Added ${imported.clients} clients, ${imported.businesses} businesses, ${imported.invoices} invoices, ${imported.expenses} expenses, ${imported.timeEntries} time entries, and ${imported.recurringInvoices} recurring invoices.`,
);
setImportData("");
setIsImportDialogOpen(false);
void refetchProfile();
},
onError: (error: { message: string }) => {
toast.error(`Import failed: ${error.message}`);
},
});
const deleteDataMutation = api.settings.deleteAllData.useMutation({
onSuccess: () => {
toast.success("All data has been permanently deleted");
setDeleteConfirmText("");
},
onError: (error: { message: string }) => {
toast.error(`Delete failed: ${error.message}`);
},
});
const handleUpdateProfile = (e: React.FormEvent) => {
e.preventDefault();
if (!name.trim()) {
toast.error("Please enter your name");
return;
}
updateProfileMutation.mutate({ name: name.trim() });
};
const handleChangePassword = (e: React.FormEvent) => {
e.preventDefault();
if (!currentPassword || !newPassword || !confirmPassword) {
toast.error("Please fill in all password fields");
return;
}
if (newPassword !== confirmPassword) {
toast.error("New passwords don't match");
return;
}
if (newPassword.length < 8) {
toast.error("New password must be at least 8 characters");
return;
}
changePasswordMutation.mutate({
currentPassword,
newPassword,
confirmPassword,
});
};
const handleExportData = async () => {
try {
const result = await exportDataQuery.refetch();
if (result.data) {
handleDownload(result.data);
}
} catch (error) {
toast.error(
`Export failed: ${error instanceof Error ? error.message : "Unknown error"}`,
);
}
};
// Type guard for backup data
const isValidBackupData = (data: unknown): boolean => {
if (typeof data !== "object" || data === null) return false;
const obj = data as Record;
return !!(
obj.exportDate &&
obj.version &&
obj.user &&
obj.clients &&
obj.businesses &&
obj.invoices &&
Array.isArray(obj.clients) &&
Array.isArray(obj.businesses) &&
Array.isArray(obj.invoices)
);
};
const handleFileUpload = (event: React.ChangeEvent) => {
const file = event.target.files?.[0];
if (!file) return;
if (!file.name.endsWith(".json")) {
toast.error("Please select a JSON file");
return;
}
const reader = new FileReader();
reader.onload = (e) => {
try {
const content = e.target?.result as string;
const parsedData: unknown = JSON.parse(content);
if (isValidBackupData(parsedData)) {
// @ts-expect-error Server handles validation of backup data format
importDataMutation.mutate(parsedData);
} else {
toast.error("Invalid backup file format");
}
} catch {
toast.error("Invalid JSON format. Please check your backup file.");
}
};
reader.onerror = () => {
toast.error("Failed to read file");
};
reader.readAsText(file);
};
const handleImportData = () => {
try {
const parsedData: unknown = JSON.parse(importData);
if (isValidBackupData(parsedData)) {
// @ts-expect-error Server handles validation of backup data format
importDataMutation.mutate(parsedData);
} else {
toast.error("Invalid backup file format");
}
} catch {
toast.error("Invalid JSON format. Please check your backup file.");
}
};
const handleDeleteAllData = () => {
if (deleteConfirmText !== "delete all my data") {
toast.error("Please type 'delete all my data' to confirm");
return;
}
deleteDataMutation.mutate({ confirmText: deleteConfirmText });
};
// Set initial name value when profile loads
React.useEffect(() => {
if (profile?.name && !name) {
// eslint-disable-next-line react-hooks/set-state-in-effect -- Sync async profile data into an editable form field.
setName(profile.name);
}
if (session?.user) {
setName(session.user.name ?? "");
}
}, [session, profile?.name, name]);
// (Removed direct DOM mutation; provider handles applying preferences globally)
const dataStatItems = [
{
label: "Clients",
value: dataStats?.clients ?? 0,
icon: Users,
color: "text-primary",
bgColor: "bg-primary/10",
},
{
label: "Businesses",
value: dataStats?.businesses ?? 0,
icon: Building,
color: "text-muted-foreground",
bgColor: "bg-muted",
},
{
label: "Invoices",
value: dataStats?.invoices ?? 0,
icon: FileText,
color: "text-primary",
bgColor: "bg-accent",
},
];
return (
General
Preferences
Data
API
{/* Profile Section */}
Profile Information
Update your personal account details
{/* Security Settings */}
Security Settings
Change your password and manage account security
{authentikEnabled && (
Connected Accounts
Manage your linked social accounts and SSO providers
Authentik SSO
Connect your corporate account
{isLinking ? "Connecting..." : "Connect"}
)}
Legal
Review how we handle your data and the terms for using{" "}
{brand.name}
Terms of Service
Privacy Policy
Appearance
Choose light, dark, or match your system setting.
Color mode
updateAppearance({
colorMode: value as typeof colorMode,
})
}
>
{colorModes.map((modeOption) => (
{modeOption.label}
))}
{
colorModes.find(
(modeOption) => modeOption.value === colorMode,
)?.description
}
{appearanceUpdating && (
Saving...
)}
{isAdmin && (
Invoice Settings
Configure generated invoice PDFs and preview the real document
output.
PDF Template
savePdfSettings({
pdfTemplate: value as PdfTemplate,
})
}
disabled={updatePdfSettingsMutation.isPending}
>
Classic
Minimal
Minimal removes shaded table fills for a cleaner
document.
undefined}
onChange={(value) => {
if (isFullHexColor(value)) {
savePdfSettings({ pdfAccentColor: value });
}
}}
className="mt-0"
/>
Footer Text
savePdfSettings({ pdfFooterText: event.target.value })
}
disabled={updatePdfSettingsMutation.isPending}
/>
Show Logo
Include the beenvoice logo in the PDF footer.
savePdfSettings({ pdfShowLogo: Boolean(checked) })
}
disabled={updatePdfSettingsMutation.isPending}
aria-label="Toggle PDF logo"
/>
Page Numbers
Show page count in the PDF footer.
savePdfSettings({
pdfShowPageNumbers: Boolean(checked),
})
}
disabled={updatePdfSettingsMutation.isPending}
aria-label="Toggle PDF page numbers"
/>
)}
{/* Accessibility & Animation */}
Accessibility & Animation
{/* Data Overview */}
Account Data
Overview of your stored information
{dataStatItems.map((item, index) => {
const Icon = item.icon;
return (
);
})}
{/* Data Management */}
Data Management
Backup, restore, or manage your account data
{exportDataQuery.isFetching
? "Exporting..."
: "Export Backup"}
Import Backup
Import Backup Data
Upload your backup JSON file or paste the contents
below. This will add the data to your existing account.
{/* Import Method Selector */}
setImportMethod("file")}
className="flex-1"
>
Upload File
setImportMethod("paste")}
className="flex-1"
>
Paste Content
{/* File Upload Method */}
{importMethod === "file" && (
)}
{/* Manual Paste Method */}
{importMethod === "paste" && (
Backup Content
)}
{
setIsImportDialogOpen(false);
setImportData("");
setImportMethod("file");
}}
>
Cancel
{importMethod === "paste" && (
{importDataMutation.isPending
? "Importing..."
: "Import Data"}
)}
{/* Backup Information */}
Backup Information
• Regular backups protect your important business data
• Backup files contain all data in secure JSON format
• Import adds to existing data without replacing
anything
• Upload JSON files directly or paste content manually
• Store backup files in a secure, accessible location
{/* Delete Account (Danger Zone) */}
Danger Zone
Irreversible actions for your account
Delete All Data
Are you absolutely sure?
This action cannot be undone. This will permanently delete
your account and remove your data from our servers.
Type delete all my data {" "}
to confirm
setDeleteConfirmText(e.target.value)}
placeholder="delete all my data"
/>
Cancel
Delete Account
);
}