Files
DynamoDNS/client/src/pages/settings.tsx
T
alphaeusmote 7823f21eb2 Enhance theme switching and add organization creation
Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 9111ef36-26c8-4085-84ca-a35dc1fec1b5
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/7083d608-d6d3-4a6a-9a27-6286c5109627/582dd4fe-d503-4ab1-81ab-a04bba654cf7.jpg
2025-04-09 23:57:26 +00:00

711 lines
26 KiB
TypeScript

import { useState } from "react";
import { useQuery, useMutation } from "@tanstack/react-query";
import { MainLayout } from "@/components/layouts/main-layout";
import { Organization, InsertOrganization } from "@shared/schema";
import { useAuth } from "@/hooks/use-auth";
import { useOrganization } from "@/context/organization-context";
import { apiRequest, queryClient } from "@/lib/queryClient";
import { useToast } from "@/hooks/use-toast";
import { useTheme } from "@/hooks/use-theme";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@/components/ui/tabs";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { Separator } from "@/components/ui/separator";
import { Loader2, Moon, Sun, Lock, Laptop } from "lucide-react";
// Organization form schema
const organizationFormSchema = z.object({
name: z.string().min(1, "Organization name is required"),
isActive: z.boolean().default(true),
});
// Create organization form schema
const createOrganizationFormSchema = z.object({
name: z.string().min(1, "Organization name is required"),
isActive: z.boolean().default(true),
});
// Account form schema
const accountFormSchema = z.object({
username: z.string().min(3, "Username must be at least 3 characters"),
email: z.string().email("Please enter a valid email"),
fullName: z.string().optional(),
});
// Password form schema
const passwordFormSchema = z.object({
currentPassword: z.string().min(1, "Current password is required"),
newPassword: z.string().min(8, "New password must be at least 8 characters"),
confirmPassword: z.string().min(8, "Please confirm your new password"),
}).refine((data) => data.newPassword === data.confirmPassword, {
message: "Passwords don't match",
path: ["confirmPassword"],
});
export default function SettingsPage() {
const { toast } = useToast();
const { user } = useAuth();
const { theme, setTheme } = useTheme();
const { currentOrganization, setCurrentOrganization, createOrganizationMutation } = useOrganization();
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
// Create organization form
const createOrganizationForm = useForm<z.infer<typeof createOrganizationFormSchema>>({
resolver: zodResolver(createOrganizationFormSchema),
defaultValues: {
name: "",
isActive: true,
},
});
// Fetch organization data
const { data: organization } = useQuery<Organization>({
queryKey: ["/api/organizations", currentOrganization?.id],
enabled: !!currentOrganization?.id,
});
// Organization form
const organizationForm = useForm<z.infer<typeof organizationFormSchema>>({
resolver: zodResolver(organizationFormSchema),
defaultValues: {
name: currentOrganization?.name || "",
isActive: currentOrganization?.isActive,
},
});
// Account form
const accountForm = useForm<z.infer<typeof accountFormSchema>>({
resolver: zodResolver(accountFormSchema),
defaultValues: {
username: user?.username || "",
email: user?.email || "",
fullName: user?.fullName || "",
},
});
// Password form
const passwordForm = useForm<z.infer<typeof passwordFormSchema>>({
resolver: zodResolver(passwordFormSchema),
defaultValues: {
currentPassword: "",
newPassword: "",
confirmPassword: "",
},
});
// Update organization details when they change
useState(() => {
if (currentOrganization) {
organizationForm.reset({
name: currentOrganization.name,
isActive: currentOrganization.isActive,
});
}
});
// Update organization mutation
const updateOrganizationMutation = useMutation({
mutationFn: async (data: z.infer<typeof organizationFormSchema>) => {
if (!currentOrganization) throw new Error("No organization selected");
const res = await apiRequest("PUT", `/api/organizations/${currentOrganization.id}`, data);
return await res.json();
},
onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: ["/api/organizations"] });
queryClient.invalidateQueries({ queryKey: ["/api/organizations", currentOrganization?.id] });
// Update the current organization in context
if (currentOrganization) {
setCurrentOrganization({
...currentOrganization,
...data,
});
}
toast({
title: "Organization updated",
description: "The organization details have been updated successfully.",
});
},
onError: (error) => {
toast({
title: "Failed to update organization",
description: error.message,
variant: "destructive",
});
},
});
// Update account mutation
const updateAccountMutation = useMutation({
mutationFn: async (data: z.infer<typeof accountFormSchema>) => {
if (!user) throw new Error("Not authenticated");
const res = await apiRequest("PUT", `/api/users/${user.id}`, data);
return await res.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["/api/user"] });
toast({
title: "Account updated",
description: "Your account details have been updated successfully.",
});
},
onError: (error) => {
toast({
title: "Failed to update account",
description: error.message,
variant: "destructive",
});
},
});
// Change password mutation
const changePasswordMutation = useMutation({
mutationFn: async (data: z.infer<typeof passwordFormSchema>) => {
if (!user) throw new Error("Not authenticated");
const res = await apiRequest("POST", "/api/change-password", {
currentPassword: data.currentPassword,
newPassword: data.newPassword,
});
return await res.json();
},
onSuccess: () => {
passwordForm.reset();
toast({
title: "Password changed",
description: "Your password has been changed successfully.",
});
},
onError: (error) => {
toast({
title: "Failed to change password",
description: error.message,
variant: "destructive",
});
},
});
// Delete organization mutation
const deleteOrganizationMutation = useMutation({
mutationFn: async () => {
if (!currentOrganization) throw new Error("No organization selected");
await apiRequest("DELETE", `/api/organizations/${currentOrganization.id}`);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["/api/organizations"] });
setIsDeleteDialogOpen(false);
toast({
title: "Organization deleted",
description: "The organization has been deleted successfully.",
});
},
onError: (error) => {
toast({
title: "Failed to delete organization",
description: error.message,
variant: "destructive",
});
},
});
// Form submission handlers
const onOrganizationSubmit = (data: z.infer<typeof organizationFormSchema>) => {
updateOrganizationMutation.mutate(data);
};
const onAccountSubmit = (data: z.infer<typeof accountFormSchema>) => {
updateAccountMutation.mutate(data);
};
const onPasswordSubmit = (data: z.infer<typeof passwordFormSchema>) => {
changePasswordMutation.mutate(data);
};
// Check if current user has admin/manager permission
const hasManagePermission = user?.role === "admin" || user?.role === "manager";
return (
<MainLayout
title="Settings"
description="Manage your account and organization settings."
>
<Tabs defaultValue="account" className="space-y-6">
<TabsList className="w-full sm:w-auto">
<TabsTrigger value="account">Account</TabsTrigger>
<TabsTrigger value="organization">Organization</TabsTrigger>
<TabsTrigger value="appearance">Appearance</TabsTrigger>
</TabsList>
{/* Account Settings */}
<TabsContent value="account" className="space-y-6">
<Card>
<CardHeader>
<CardTitle>Account Information</CardTitle>
<CardDescription>
Update your personal account details.
</CardDescription>
</CardHeader>
<CardContent>
<Form {...accountForm}>
<form onSubmit={accountForm.handleSubmit(onAccountSubmit)} className="space-y-4">
<FormField
control={accountForm.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>Username</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={accountForm.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input type="email" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={accountForm.control}
name="fullName"
render={({ field }) => (
<FormItem>
<FormLabel>Full Name</FormLabel>
<FormControl>
<Input {...field} value={field.value || ""} />
</FormControl>
<FormDescription>
Your full name for display purposes
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<Button
type="submit"
disabled={updateAccountMutation.isPending}
>
{updateAccountMutation.isPending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Saving...
</>
) : (
"Save Changes"
)}
</Button>
</form>
</Form>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Change Password</CardTitle>
<CardDescription>
Update your account password.
</CardDescription>
</CardHeader>
<CardContent>
<Form {...passwordForm}>
<form onSubmit={passwordForm.handleSubmit(onPasswordSubmit)} className="space-y-4">
<FormField
control={passwordForm.control}
name="currentPassword"
render={({ field }) => (
<FormItem>
<FormLabel>Current Password</FormLabel>
<FormControl>
<Input type="password" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={passwordForm.control}
name="newPassword"
render={({ field }) => (
<FormItem>
<FormLabel>New Password</FormLabel>
<FormControl>
<Input type="password" {...field} />
</FormControl>
<FormDescription>
Must be at least 8 characters long
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={passwordForm.control}
name="confirmPassword"
render={({ field }) => (
<FormItem>
<FormLabel>Confirm New Password</FormLabel>
<FormControl>
<Input type="password" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button
type="submit"
disabled={changePasswordMutation.isPending}
>
{changePasswordMutation.isPending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Changing Password...
</>
) : (
"Change Password"
)}
</Button>
</form>
</Form>
</CardContent>
</Card>
</TabsContent>
{/* Organization Settings */}
<TabsContent value="organization" className="space-y-6">
{!hasManagePermission ? (
<Card>
<CardContent className="pt-6 flex flex-col items-center justify-center h-64">
<Lock className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-medium mb-2">Access Restricted</h3>
<p className="text-center text-muted-foreground">
You don't have permission to modify organization settings. Only administrators and managers can manage organizations.
</p>
</CardContent>
</Card>
) : (
<>
{user?.role === "admin" && (
<Card>
<CardHeader>
<CardTitle>Create New Organization</CardTitle>
<CardDescription>
Add a new organization to your account
</CardDescription>
</CardHeader>
<CardContent>
<Form {...createOrganizationForm}>
<form
onSubmit={createOrganizationForm.handleSubmit((data) => {
createOrganizationMutation.mutate(data);
createOrganizationForm.reset();
})}
className="space-y-4"
>
<FormField
control={createOrganizationForm.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Organization Name</FormLabel>
<FormControl>
<Input {...field} placeholder="Enter organization name" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={createOrganizationForm.control}
name="isActive"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5">
<FormLabel className="text-base">Active Status</FormLabel>
<FormDescription>
Enable this organization upon creation
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<Button
type="submit"
disabled={createOrganizationMutation.isPending}
>
{createOrganizationMutation.isPending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Creating...
</>
) : (
"Create Organization"
)}
</Button>
</form>
</Form>
</CardContent>
</Card>
)}
<Card>
<CardHeader>
<CardTitle>Organization Details</CardTitle>
<CardDescription>
Manage your organization information.
</CardDescription>
</CardHeader>
<CardContent>
<Form {...organizationForm}>
<form onSubmit={organizationForm.handleSubmit(onOrganizationSubmit)} className="space-y-4">
<FormField
control={organizationForm.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Organization Name</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={organizationForm.control}
name="isActive"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5">
<FormLabel className="text-base">Active Status</FormLabel>
<FormDescription>
Enable or disable this organization
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<Button
type="submit"
disabled={updateOrganizationMutation.isPending}
>
{updateOrganizationMutation.isPending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Saving...
</>
) : (
"Save Changes"
)}
</Button>
</form>
</Form>
</CardContent>
</Card>
{user?.role === "admin" && (
<Card className="border-destructive">
<CardHeader>
<CardTitle>Danger Zone</CardTitle>
<CardDescription>
Irreversible actions for your organization
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="rounded-lg border border-destructive p-4">
<div className="flex flex-row items-center justify-between">
<div>
<h4 className="font-medium">Delete Organization</h4>
<p className="text-sm text-muted-foreground">
Permanently delete this organization and all associated data
</p>
</div>
<Button
variant="destructive"
onClick={() => setIsDeleteDialogOpen(true)}
>
Delete
</Button>
</div>
</div>
</div>
</CardContent>
</Card>
)}
</>
)}
</TabsContent>
{/* Appearance Settings */}
<TabsContent value="appearance" className="space-y-6">
<Card>
<CardHeader>
<CardTitle>Appearance</CardTitle>
<CardDescription>
Customize the application appearance and theme.
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="space-y-2">
<h3 className="text-lg font-medium">Theme</h3>
<p className="text-sm text-muted-foreground">
Select your preferred theme appearance
</p>
<div className="grid grid-cols-3 gap-4 pt-2">
<div
className={`flex flex-col items-center justify-between rounded-md border-2 border-muted bg-popover p-4 hover:bg-accent hover:text-accent-foreground cursor-pointer ${
theme === "light" ? "border-primary" : ""
}`}
onClick={() => setTheme("light")}
>
<Sun className="h-6 w-6 mb-3" />
<div className="space-y-1 text-center">
<h3 className="font-medium">Light</h3>
<p className="text-xs text-muted-foreground">Light background with dark text</p>
</div>
</div>
<div
className={`flex flex-col items-center justify-between rounded-md border-2 border-muted bg-popover p-4 hover:bg-accent hover:text-accent-foreground cursor-pointer ${
theme === "dark" ? "border-primary" : ""
}`}
onClick={() => setTheme("dark")}
>
<Moon className="h-6 w-6 mb-3" />
<div className="space-y-1 text-center">
<h3 className="font-medium">Dark</h3>
<p className="text-xs text-muted-foreground">Dark background with light text</p>
</div>
</div>
<div
className={`flex flex-col items-center justify-between rounded-md border-2 border-muted bg-popover p-4 hover:bg-accent hover:text-accent-foreground cursor-pointer ${
theme === "system" ? "border-primary" : ""
}`}
onClick={() => setTheme("system")}
>
<Laptop className="h-6 w-6 mb-3" />
<div className="space-y-1 text-center">
<h3 className="font-medium">System</h3>
<p className="text-xs text-muted-foreground">Follow your system preference</p>
</div>
</div>
</div>
</div>
</CardContent>
</Card>
</TabsContent>
</Tabs>
{/* Delete Organization Dialog */}
<AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Organization</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete the organization <strong>{currentOrganization?.name}</strong>?
This action cannot be undone. All domains, DNS records, and related data will be permanently deleted.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => deleteOrganizationMutation.mutate()}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
disabled={deleteOrganizationMutation.isPending}
>
{deleteOrganizationMutation.isPending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Deleting...
</>
) : (
"Delete Organization"
)}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</MainLayout>
);
}