website-monitor/frontend/app/settings/page.tsx

472 lines
24 KiB
TypeScript
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client'
import { useState } from 'react'
import { useQuery, useMutation } from '@tanstack/react-query'
import { useRouter } from 'next/navigation'
import { toast } from 'sonner'
import { DashboardLayout } from '@/components/layout/dashboard-layout'
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Badge } from '@/components/ui/badge'
import { settingsAPI } from '@/lib/api'
import { clearAuth } from '@/lib/auth'
import { usePlan } from '@/lib/use-plan'
export default function SettingsPage() {
const router = useRouter()
const [showPasswordForm, setShowPasswordForm] = useState(false)
const [showWebhookForm, setShowWebhookForm] = useState(false)
const [showSlackForm, setShowSlackForm] = useState(false)
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
const { canUseSlack, canUseWebhook } = usePlan()
const [passwordForm, setPasswordForm] = useState({
currentPassword: '',
newPassword: '',
confirmPassword: '',
})
const [webhookUrl, setWebhookUrl] = useState('')
const [slackWebhookUrl, setSlackWebhookUrl] = useState('')
const [deletePassword, setDeletePassword] = useState('')
// Fetch user settings
const { data: settings, isLoading, refetch } = useQuery({
queryKey: ['settings'],
queryFn: async () => {
const response = await settingsAPI.get()
setWebhookUrl(response.settings.webhookUrl || '')
setSlackWebhookUrl(response.settings.slackWebhookUrl || '')
return response.settings
},
})
// Change password mutation
const changePasswordMutation = useMutation({
mutationFn: async () => {
if (passwordForm.newPassword !== passwordForm.confirmPassword) {
throw new Error('Passwords do not match')
}
if (passwordForm.newPassword.length < 8) {
throw new Error('Password must be at least 8 characters')
}
return settingsAPI.changePassword(passwordForm.currentPassword, passwordForm.newPassword)
},
onSuccess: () => {
toast.success('Password changed successfully')
setShowPasswordForm(false)
setPasswordForm({ currentPassword: '', newPassword: '', confirmPassword: '' })
},
onError: (error: any) => {
toast.error(error.response?.data?.message || error.message || 'Failed to change password')
},
})
// Toggle email notifications
const toggleEmailMutation = useMutation({
mutationFn: async (enabled: boolean) => {
return settingsAPI.updateNotifications({ emailEnabled: enabled })
},
onSuccess: () => {
toast.success('Email notifications updated')
refetch()
},
onError: (error: any) => {
toast.error(error.response?.data?.message || 'Failed to update notifications')
},
})
// Update webhook
const updateWebhookMutation = useMutation({
mutationFn: async () => {
return settingsAPI.updateNotifications({
webhookUrl: webhookUrl || null,
webhookEnabled: !!webhookUrl,
})
},
onSuccess: () => {
toast.success('Webhook settings updated')
setShowWebhookForm(false)
refetch()
},
onError: (error: any) => {
toast.error(error.response?.data?.message || 'Failed to update webhook')
},
})
// Update Slack
const updateSlackMutation = useMutation({
mutationFn: async () => {
return settingsAPI.updateNotifications({
slackWebhookUrl: slackWebhookUrl || null,
slackEnabled: !!slackWebhookUrl,
})
},
onSuccess: () => {
toast.success('Slack integration updated')
setShowSlackForm(false)
refetch()
},
onError: (error: any) => {
toast.error(error.response?.data?.message || 'Failed to update Slack')
},
})
// Delete account mutation
const deleteAccountMutation = useMutation({
mutationFn: async () => {
return settingsAPI.deleteAccount(deletePassword)
},
onSuccess: () => {
toast.success('Account deleted successfully')
clearAuth()
router.push('/login')
},
onError: (error: any) => {
toast.error(error.response?.data?.message || 'Failed to delete account')
},
})
if (isLoading) {
return (
<DashboardLayout title="Settings" description="Manage your account and preferences">
<div className="flex items-center justify-center py-12">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent"></div>
</div>
</DashboardLayout>
)
}
return (
<DashboardLayout title="Settings" description="Manage your account and preferences">
{/* Account Settings */}
<Card className="mb-6">
<CardHeader>
<CardTitle>Account</CardTitle>
<CardDescription>Manage your account settings</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<Input
label="Email"
type="email"
value={settings?.email || ''}
disabled
/>
<div className="flex items-center gap-2">
<Badge>{settings?.plan || 'free'}</Badge>
<span className="text-sm text-muted-foreground">plan</span>
</div>
{!showPasswordForm ? (
<Button variant="outline" onClick={() => setShowPasswordForm(true)}>
Change Password
</Button>
) : (
<div className="space-y-3 rounded-lg border border-primary/20 bg-primary/5 p-4">
<Input
label="Current Password"
type="password"
value={passwordForm.currentPassword}
onChange={(e) => setPasswordForm({ ...passwordForm, currentPassword: e.target.value })}
required
/>
<Input
label="New Password"
type="password"
value={passwordForm.newPassword}
onChange={(e) => setPasswordForm({ ...passwordForm, newPassword: e.target.value })}
hint="At least 8 characters"
required
/>
<Input
label="Confirm New Password"
type="password"
value={passwordForm.confirmPassword}
onChange={(e) => setPasswordForm({ ...passwordForm, confirmPassword: e.target.value })}
required
/>
<div className="flex gap-2">
<Button
onClick={() => changePasswordMutation.mutate()}
disabled={changePasswordMutation.isPending}
>
{changePasswordMutation.isPending ? 'Saving...' : 'Save Password'}
</Button>
<Button
variant="outline"
onClick={() => {
setShowPasswordForm(false)
setPasswordForm({ currentPassword: '', newPassword: '', confirmPassword: '' })
}}
>
Cancel
</Button>
</div>
</div>
)}
</CardContent>
</Card>
{/* Notifications */}
<Card className="mb-6">
<CardHeader>
<CardTitle>Notifications</CardTitle>
<CardDescription>Configure how you receive alerts</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
{/* Email Notifications */}
<div className="flex items-center justify-between rounded-lg border border-border p-4">
<div>
<p className="font-medium">Email Notifications</p>
<p className="text-sm text-muted-foreground">Receive email alerts when changes are detected</p>
</div>
<Button
variant={settings?.emailEnabled !== false ? 'success' : 'outline'}
size="sm"
onClick={() => toggleEmailMutation.mutate(settings?.emailEnabled === false)}
disabled={toggleEmailMutation.isPending}
>
{settings?.emailEnabled !== false ? 'Enabled' : 'Disabled'}
</Button>
</div>
{/* Slack Integration */}
<div className="rounded-lg border border-border p-4">
<div className="flex items-center justify-between">
<div>
<p className="font-medium">Slack Integration</p>
<p className="text-sm text-muted-foreground">Send alerts to your Slack workspace</p>
{settings?.slackEnabled && (
<p className="mt-1 text-xs text-green-600"> Configured</p>
)}
{!canUseSlack && (
<div className="mt-1 flex items-center gap-1.5 rounded bg-muted/50 px-2 py-0.5 w-fit">
<svg className="h-3 w-3 text-muted-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">Pro Feature</span>
</div>
)}
</div>
<Button
variant="outline"
size="sm"
onClick={() => setShowSlackForm(!showSlackForm)}
disabled={!canUseSlack}
>
{settings?.slackEnabled ? 'Reconfigure' : 'Configure'}
</Button>
</div>
{showSlackForm && (
<div className="mt-4 space-y-3 rounded-lg border border-primary/20 bg-primary/5 p-3">
<Input
label="Slack Webhook URL"
type="url"
value={slackWebhookUrl}
onChange={(e) => setSlackWebhookUrl(e.target.value)}
placeholder="https://hooks.slack.com/services/..."
hint="Get this from your Slack app settings"
/>
<div className="flex gap-2">
<Button
onClick={() => updateSlackMutation.mutate()}
disabled={updateSlackMutation.isPending}
size="sm"
>
{updateSlackMutation.isPending ? 'Saving...' : 'Save'}
</Button>
<Button
variant="outline"
onClick={() => setShowSlackForm(false)}
size="sm"
>
Cancel
</Button>
{settings?.slackEnabled && (
<Button
variant="destructive"
onClick={() => {
setSlackWebhookUrl('')
updateSlackMutation.mutate()
}}
size="sm"
>
Remove
</Button>
)}
</div>
</div>
)}
</div>
{/* Webhook */}
<div className="rounded-lg border border-border p-4">
<div className="flex items-center justify-between">
<div>
<p className="font-medium">Webhook</p>
<p className="text-sm text-muted-foreground">Send JSON payloads to your server</p>
{settings?.webhookEnabled && (
<p className="mt-1 text-xs text-green-600"> Configured</p>
)}
{!canUseWebhook && (
<div className="mt-1 flex items-center gap-1.5 rounded bg-muted/50 px-2 py-0.5 w-fit">
<svg className="h-3 w-3 text-muted-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">Pro Feature</span>
</div>
)}
</div>
<Button
variant="outline"
size="sm"
onClick={() => setShowWebhookForm(!showWebhookForm)}
disabled={!canUseWebhook}
>
{settings?.webhookEnabled ? 'Reconfigure' : 'Configure'}
</Button>
</div>
{showWebhookForm && (
<div className="mt-4 space-y-3 rounded-lg border border-primary/20 bg-primary/5 p-3">
<Input
label="Webhook URL"
type="url"
value={webhookUrl}
onChange={(e) => setWebhookUrl(e.target.value)}
placeholder="https://your-server.com/webhook"
hint="We'll POST JSON data to this URL on changes"
/>
<div className="flex gap-2">
<Button
onClick={() => updateWebhookMutation.mutate()}
disabled={updateWebhookMutation.isPending}
size="sm"
>
{updateWebhookMutation.isPending ? 'Saving...' : 'Save'}
</Button>
<Button
variant="outline"
onClick={() => setShowWebhookForm(false)}
size="sm"
>
Cancel
</Button>
{settings?.webhookEnabled && (
<Button
variant="destructive"
onClick={() => {
setWebhookUrl('')
updateWebhookMutation.mutate()
}}
size="sm"
>
Remove
</Button>
)}
</div>
</div>
)}
</div>
</div>
</CardContent>
</Card>
{/* Plan & Billing */}
<Card className="mb-6">
<CardHeader>
<CardTitle>Plan & Billing</CardTitle>
<CardDescription>Manage your subscription</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between rounded-lg border border-primary/30 bg-primary/5 p-6">
<div>
<div className="flex items-center gap-2">
<p className="text-lg font-bold capitalize">{settings?.plan || 'Free'} Plan</p>
<Badge>Current</Badge>
</div>
<p className="mt-1 text-sm text-muted-foreground">
{settings?.plan === 'free' && '5 monitors, 1hr frequency'}
{settings?.plan === 'pro' && '50 monitors, 5min frequency'}
{settings?.plan === 'business' && '200 monitors, 1min frequency'}
{settings?.plan === 'enterprise' && 'Unlimited monitors, all features'}
</p>
{settings?.plan !== 'free' && (
<p className="mt-2 text-sm text-muted-foreground">
Stripe Customer ID: {settings?.stripeCustomerId || 'N/A'}
</p>
)}
</div>
<Button variant="outline" disabled>
Manage Plan
</Button>
</div>
</CardContent>
</Card>
{/* Danger Zone */}
<Card className="border-destructive/30">
<CardHeader>
<CardTitle className="text-destructive">Danger Zone</CardTitle>
<CardDescription>Irreversible actions</CardDescription>
</CardHeader>
<CardContent>
{!showDeleteConfirm ? (
<div className="flex items-center justify-between">
<div>
<p className="font-medium">Delete Account</p>
<p className="text-sm text-muted-foreground">Permanently delete your account and all data</p>
</div>
<Button
variant="destructive"
size="sm"
onClick={() => setShowDeleteConfirm(true)}
>
Delete Account
</Button>
</div>
) : (
<div className="space-y-3 rounded-lg border border-destructive/30 bg-destructive/5 p-4">
<div className="mb-2">
<p className="font-semibold text-destructive"> This action cannot be undone!</p>
<p className="text-sm text-muted-foreground">
All your monitors, snapshots, and alerts will be permanently deleted.
</p>
</div>
<Input
label="Confirm with your password"
type="password"
value={deletePassword}
onChange={(e) => setDeletePassword(e.target.value)}
placeholder="Enter your password"
/>
<div className="flex gap-2">
<Button
variant="destructive"
onClick={() => deleteAccountMutation.mutate()}
disabled={!deletePassword || deleteAccountMutation.isPending}
>
{deleteAccountMutation.isPending ? 'Deleting...' : 'Yes, Delete My Account'}
</Button>
<Button
variant="outline"
onClick={() => {
setShowDeleteConfirm(false)
setDeletePassword('')
}}
>
Cancel
</Button>
</div>
</div>
)}
</CardContent>
</Card>
</DashboardLayout>
)
}