Files
beenvoice/src/app/dashboard/settings/_components/settings-content.tsx

913 lines
33 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import {
AlertTriangle,
Building,
ChevronDown,
Database,
Download,
Eye,
EyeOff,
FileText,
FileUp,
Info,
Key,
Palette,
Shield,
Upload,
User,
Users,
Link as LinkIcon,
} from "lucide-react";
import { authClient } from "~/lib/auth-client";
import * as React from "react";
import { useState } from "react";
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 { Label } from "~/components/ui/label";
import { Textarea } from "~/components/ui/textarea";
import { api } from "~/trpc/react";
import { Switch } from "~/components/ui/switch";
import { Slider } from "~/components/ui/slider";
import { useAnimationPreferences } from "~/components/providers/animation-preferences-provider";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
export function SettingsContent() {
const { data: session } = authClient.useSession();
// const session = { user: null } as any;
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 handleLinkAuthentik = async () => {
setIsLinking(true);
try {
await authClient.linkSocial({
provider: "authentik",
callbackURL: "/dashboard/settings",
});
} catch (error) {
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 { 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) => {
toast.success(
`Data imported successfully! Added ${result.imported.clients} clients, ${result.imported.businesses} businesses, and ${result.imported.invoices} 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<string, unknown>;
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<HTMLInputElement>) => {
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) {
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 (
<Tabs defaultValue="general" className="space-y-4">
<TabsList className="bg-muted/50 grid w-full grid-cols-3 lg:w-[400px]">
<TabsTrigger value="general">General</TabsTrigger>
<TabsTrigger value="preferences">Preferences</TabsTrigger>
<TabsTrigger value="data">Data</TabsTrigger>
</TabsList>
<TabsContent value="general" className="space-y-8">
<div className="grid gap-6 lg:grid-cols-2">
{/* Profile Section */}
<Card className="form-section bg-card border-border border">
<CardHeader>
<CardTitle className="text-foreground flex items-center gap-2">
<User className="text-primary h-5 w-5" />
Profile Information
</CardTitle>
<CardDescription>
Update your personal account details
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleUpdateProfile} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name">Full Name</Label>
<Input
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Enter your full name"
/>
</div>
<div className="space-y-2">
<Label htmlFor="email">Email Address</Label>
<Input
id="email"
value={session?.user?.email ?? ""}
disabled
className="bg-muted"
/>
<p className="text-muted-foreground text-sm">
Email address cannot be changed
</p>
</div>
<Button
type="submit"
disabled={updateProfileMutation.isPending}
variant="default"
className="hover-lift"
>
{updateProfileMutation.isPending
? "Updating..."
: "Save Changes"}
</Button>
</form>
</CardContent>
</Card>
{/* Security Settings */}
<Card className="bg-card border-border border">
<CardHeader>
<CardTitle className="text-foreground flex items-center gap-2">
<Key className="text-primary h-5 w-5" />
Security Settings
</CardTitle>
<CardDescription>
Change your password and manage account security
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleChangePassword} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="current-password">Current Password</Label>
<div className="relative">
<Input
id="current-password"
type={showCurrentPassword ? "text" : "password"}
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
placeholder="Enter your current password"
/>
<Button
type="button"
variant="ghost"
size="sm"
className="absolute top-1/2 right-2 h-6 w-6 -translate-y-1/2 p-0"
onClick={() => setShowCurrentPassword(!showCurrentPassword)}
>
{showCurrentPassword ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</Button>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="new-password">New Password</Label>
<div className="relative">
<Input
id="new-password"
type={showNewPassword ? "text" : "password"}
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
placeholder="Enter your new password"
/>
<Button
type="button"
variant="ghost"
size="sm"
className="absolute top-1/2 right-2 h-6 w-6 -translate-y-1/2 p-0"
onClick={() => setShowNewPassword(!showNewPassword)}
>
{showNewPassword ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</Button>
</div>
<p className="text-muted-foreground text-sm">
Password must be at least 8 characters long
</p>
</div>
<div className="space-y-2">
<Label htmlFor="confirm-password">Confirm New Password</Label>
<div className="relative">
<Input
id="confirm-password"
type={showConfirmPassword ? "text" : "password"}
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="Confirm your new password"
/>
<Button
type="button"
variant="ghost"
size="sm"
className="absolute top-1/2 right-2 h-6 w-6 -translate-y-1/2 p-0"
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
>
{showConfirmPassword ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</Button>
</div>
</div>
<Button
type="submit"
disabled={changePasswordMutation.isPending}
variant="default"
>
{changePasswordMutation.isPending
? "Changing Password..."
: "Change Password"}
</Button>
</form>
</CardContent>
</Card>
{/* Connected Accounts */}
<Card className="bg-card border-border border">
<CardHeader>
<CardTitle className="text-foreground flex items-center gap-2">
<LinkIcon className="text-primary h-5 w-5" />
Connected Accounts
</CardTitle>
<CardDescription>
Manage your linked social accounts and SSO providers
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="flex items-center justify-between rounded-lg border p-4">
<div className="flex items-center gap-4">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-blue-500/10">
<Shield className="h-5 w-5 text-blue-500" />
</div>
<div className="space-y-1">
<p className="font-medium leading-none">Authentik SSO</p>
<p className="text-muted-foreground text-sm">
Connect your corporate account
</p>
</div>
</div>
<Button
variant="outline"
disabled={isLinking}
onClick={handleLinkAuthentik}
>
{isLinking ? "Connecting..." : "Connect"}
</Button>
</div>
</div>
</CardContent>
</Card>
</div>
</TabsContent>
<TabsContent value="preferences" className="space-y-8">
{/* Theme follows system preferences automatically via CSS media queries */}
{/* Accessibility & Animation */}
<Card className="bg-card border-border border">
<CardHeader>
<CardTitle className="text-foreground flex items-center gap-2">
<Info className="text-primary h-5 w-5" />
Accessibility & Animation
</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleSaveAnimationPreferences} className="space-y-6">
<div className="flex items-start justify-between gap-4">
<div className="space-y-1.5">
<Label>Reduce Motion</Label>
<p className="text-muted-foreground text-xs leading-snug">
Turn this on to reduce or remove non-essential animations and
transitions.
</p>
</div>
<Switch
checked={prefersReducedMotion}
onCheckedChange={(checked) =>
setPrefersReducedMotion(Boolean(checked))
}
aria-label="Toggle reduced motion"
/>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="mb-0">Animation Speed Multiplier</Label>
<span className="text-muted-foreground text-xs font-medium">
{prefersReducedMotion
? "1.00x (locked)"
: `${animationSpeedMultiplier.toFixed(2)}x`}
</span>
</div>
<p className="text-muted-foreground text-xs leading-snug">
Adjust global animation duration scaling. Lower values (0.25×,
0.5×, 0.75×) slow animations; higher values (2×, 3×, 4×) speed
them up.
</p>
<div className="flex flex-col gap-2">
{/* Slider (desktop / larger screens) */}
<div className="hidden sm:block">
<Slider
value={[animationSpeedMultiplier]}
min={0.25}
max={4}
step={0.25}
ticks={[0.25, 0.5, 0.75, 1, 2, 3, 4]}
formatTick={(t) => (t === 1 ? "1x" : `${t}x`)}
onValueChange={(v: number[]) =>
setAnimationSpeedMultiplier(v[0] ?? 1)
}
aria-label="Animation speed multiplier"
className="mt-1"
disabled={prefersReducedMotion}
/>
</div>
{/* Dropdown fallback (small screens) */}
<div className="block sm:hidden">
<select
className="bg-background border-border text-foreground w-full rounded-md border px-2 py-2 text-sm disabled:opacity-60"
value={animationSpeedMultiplier}
disabled={prefersReducedMotion}
onChange={(e) =>
setAnimationSpeedMultiplier(
parseFloat(e.target.value) || 1,
)
}
aria-label="Animation speed multiplier select"
>
{[0.25, 0.5, 0.75, 1, 2, 3, 4].map((v) => (
<option key={v} value={v}>
{v === 1 ? "1x (default)" : `${v}x`}
</option>
))}
</select>
</div>
</div>
</div>
<Button
type="submit"
disabled={animationPrefsUpdating}
variant="default"
>
{animationPrefsUpdating ? "Saving..." : "Save Preferences"}
</Button>
</form>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="data" className="space-y-8">
{/* Data Overview */}
<Card className="form-section bg-card border-border border">
<CardHeader>
<CardTitle className="text-foreground flex items-center gap-2">
<Database className="text-primary h-5 w-5" />
Account Data
</CardTitle>
<CardDescription>
Overview of your stored information
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
{dataStatItems.map((item, index) => {
const Icon = item.icon;
return (
<div
key={item.label}
className="bg-card rounded-lg border p-4 transition-shadow hover:shadow-sm"
style={{ animationDelay: `${index * 100}ms` }}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className={`p-2 ${item.bgColor}`}>
<Icon className={`h-4 w-4 ${item.color}`} />
</div>
<span className="font-medium break-words">
{item.label}
</span>
</div>
<div className="text-right">
<span className="text-foreground text-2xl font-bold">
{item.value}
</span>
</div>
</div>
</div>
);
})}
</div>
</CardContent>
</Card>
{/* Data Management */}
<Card className="bg-card border-border border">
<CardHeader>
<CardTitle className="text-foreground flex items-center gap-2">
<Shield className="text-primary h-5 w-5" />
Data Management
</CardTitle>
<CardDescription>
Backup, restore, or manage your account data
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-6">
<div className="flex flex-col gap-3 sm:flex-row sm:gap-4">
<Button
onClick={handleExportData}
disabled={exportDataQuery.isFetching}
variant="outline"
className="w-full sm:flex-1"
>
<Download className="mr-2 h-4 w-4" />
{exportDataQuery.isFetching ? "Exporting..." : "Export Backup"}
</Button>
<Dialog
open={isImportDialogOpen}
onOpenChange={setIsImportDialogOpen}
>
<DialogTrigger asChild>
<Button variant="outline" className="w-full sm:flex-1">
<Upload className="mr-2 h-4 w-4" />
Import Backup
</Button>
</DialogTrigger>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Import Backup Data</DialogTitle>
<DialogDescription>
Upload your backup JSON file or paste the contents below.
This will add the data to your existing account.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{/* Import Method Selector */}
<div className="flex gap-2">
<Button
type="button"
variant={
importMethod === "file" ? "default" : "outline"
}
size="sm"
onClick={() => setImportMethod("file")}
className="flex-1"
>
<FileUp className="mr-2 h-4 w-4" />
Upload File
</Button>
<Button
type="button"
variant={
importMethod === "paste" ? "default" : "outline"
}
size="sm"
onClick={() => setImportMethod("paste")}
className="flex-1"
>
<FileText className="mr-2 h-4 w-4" />
Paste Content
</Button>
</div>
{/* File Upload Method */}
{importMethod === "file" && (
<div className="space-y-2">
<Label htmlFor="backup-file">Select Backup File</Label>
<Input
id="backup-file"
type="file"
accept=".json"
onChange={handleFileUpload}
disabled={importDataMutation.isPending}
/>
<p className="text-muted-foreground text-sm">
Select the JSON backup file you previously exported.
</p>
</div>
)}
{/* Manual Paste Method */}
{importMethod === "paste" && (
<div className="space-y-2">
<Label htmlFor="backup-content">Backup Content</Label>
<Textarea
id="backup-content"
placeholder="Paste your backup JSON data here..."
value={importData}
onChange={(e) => setImportData(e.target.value)}
rows={12}
className="font-mono text-sm"
/>
</div>
)}
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => {
setIsImportDialogOpen(false);
setImportData("");
setImportMethod("file");
}}
>
Cancel
</Button>
{importMethod === "paste" && (
<Button
onClick={handleImportData}
disabled={
!importData.trim() || importDataMutation.isPending
}
variant="default"
>
{importDataMutation.isPending
? "Importing..."
: "Import Data"}
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
</div>
{/* Backup Information */}
<Collapsible>
<CollapsibleTrigger asChild>
<Button variant="ghost" className="w-full justify-between p-0">
<div className="flex items-center gap-2">
<Info className="h-4 w-4" />
<span className="font-medium">Backup Information</span>
</div>
<ChevronDown className="h-4 w-4" />
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="mt-3">
<div className="border-border bg-muted/20 border p-4">
<ul className="text-muted-foreground space-y-1 text-sm">
<li>
Regular backups protect your important business data
</li>
<li>
Backup files contain all data in secure JSON format
</li>
<li>
Import adds to existing data without replacing anything
</li>
<li>
Upload JSON files directly or paste content manually
</li>
<li>
Store backup files in a secure, accessible location
</li>
</ul>
</div>
</CollapsibleContent>
</Collapsible>
</div>
</CardContent>
</Card>
{/* Delete Account (Danger Zone) */}
<Card className="border-destructive/50 bg-destructive/5 border">
<CardHeader>
<CardTitle className="text-destructive flex items-center gap-2">
<AlertTriangle className="h-5 w-5" />
Danger Zone
</CardTitle>
<CardDescription className="text-destructive/80">
Irreversible actions for your account
</CardDescription>
</CardHeader>
<CardContent>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="destructive" className="w-full sm:w-auto">
Delete All Data
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone. This will permanently delete your
account and remove your data from our servers.
</AlertDialogDescription>
</AlertDialogHeader>
<div className="my-4 space-y-2">
<Label htmlFor="confirm-delete">
Type <span className="font-bold">delete all my data</span> to
confirm
</Label>
<Input
id="confirm-delete"
value={deleteConfirmText}
onChange={(e) => setDeleteConfirmText(e.target.value)}
placeholder="delete all my data"
/>
</div>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleDeleteAllData}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
disabled={deleteConfirmText !== "delete all my data"}
>
Delete Account
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</CardContent>
</Card>
</TabsContent>
</Tabs>
);
}