gitea
This commit is contained in:
471
frontend/app/settings/page.tsx
Normal file
471
frontend/app/settings/page.tsx
Normal file
@@ -0,0 +1,471 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user