diff --git a/frontend/src/components/settings/NtfySettingsSection.tsx b/frontend/src/components/settings/NtfySettingsSection.tsx new file mode 100644 index 0000000..f4053ce --- /dev/null +++ b/frontend/src/components/settings/NtfySettingsSection.tsx @@ -0,0 +1,356 @@ +import { useState, useEffect } from 'react'; +import { toast } from 'sonner'; +import { + AlertTriangle, + Bell, + Eye, + EyeOff, + Loader2, + Send, + X, +} from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Select } from '@/components/ui/select'; +import { Switch } from '@/components/ui/switch'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Separator } from '@/components/ui/separator'; +import api, { getErrorMessage } from '@/lib/api'; +import type { Settings } from '@/types'; + +interface NtfySettingsSectionProps { + settings: Settings | undefined; + updateSettings: ( + updates: Partial & { preferred_name?: string | null; ntfy_auth_token?: string } + ) => Promise; +} + +export default function NtfySettingsSection({ settings, updateSettings }: NtfySettingsSectionProps) { + // ── Local form state ── + const [ntfyEnabled, setNtfyEnabled] = useState(false); + const [ntfyServerUrl, setNtfyServerUrl] = useState(''); + const [ntfyTopic, setNtfyTopic] = useState(''); + const [ntfyToken, setNtfyToken] = useState(''); + const [tokenCleared, setTokenCleared] = useState(false); + const [showToken, setShowToken] = useState(false); + + // Per-type toggles + const [eventsEnabled, setEventsEnabled] = useState(false); + const [eventLeadMinutes, setEventLeadMinutes] = useState(15); + const [remindersEnabled, setRemindersEnabled] = useState(false); + const [todosEnabled, setTodosEnabled] = useState(false); + const [todoLeadDays, setTodoLeadDays] = useState(0); + const [projectsEnabled, setProjectsEnabled] = useState(false); + const [projectLeadDays, setProjectLeadDays] = useState(0); + + const [isSaving, setIsSaving] = useState(false); + const [isTestingNtfy, setIsTestingNtfy] = useState(false); + + // Sync from settings on initial load + useEffect(() => { + if (!settings) return; + setNtfyEnabled(settings.ntfy_enabled ?? false); + setNtfyServerUrl(settings.ntfy_server_url ?? ''); + setNtfyTopic(settings.ntfy_topic ?? ''); + setNtfyToken(''); // never pre-populate token value — backend returns has_token only + setTokenCleared(false); + setEventsEnabled(settings.ntfy_events_enabled ?? false); + setEventLeadMinutes(settings.ntfy_event_lead_minutes ?? 15); + setRemindersEnabled(settings.ntfy_reminders_enabled ?? false); + setTodosEnabled(settings.ntfy_todos_enabled ?? false); + setTodoLeadDays(settings.ntfy_todo_lead_days ?? 0); + setProjectsEnabled(settings.ntfy_projects_enabled ?? false); + setProjectLeadDays(settings.ntfy_project_lead_days ?? 0); + }, [settings?.id]); + + const ntfyHasToken = settings?.ntfy_has_token ?? false; + const isMisconfigured = ntfyEnabled && (!ntfyServerUrl.trim() || !ntfyTopic.trim()); + + const handleSave = async () => { + setIsSaving(true); + try { + const updates: Partial & { ntfy_auth_token?: string } = { + ntfy_enabled: ntfyEnabled, + ntfy_server_url: ntfyServerUrl.trim() || null, + ntfy_topic: ntfyTopic.trim() || null, + ntfy_events_enabled: eventsEnabled, + ntfy_event_lead_minutes: eventLeadMinutes, + ntfy_reminders_enabled: remindersEnabled, + ntfy_todos_enabled: todosEnabled, + ntfy_todo_lead_days: todoLeadDays, + ntfy_projects_enabled: projectsEnabled, + ntfy_project_lead_days: projectLeadDays, + }; + + // Token logic: include only if user typed a new token OR explicitly cleared it + if (ntfyToken) { + updates.ntfy_auth_token = ntfyToken; + } else if (tokenCleared) { + updates.ntfy_auth_token = ''; // backend normalizes "" → None + } + + await updateSettings(updates); + setNtfyToken(''); + setTokenCleared(false); + toast.success('Notification settings saved'); + } catch (error) { + toast.error(getErrorMessage(error, 'Failed to save notification settings')); + } finally { + setIsSaving(false); + } + }; + + const handleNtfyTest = async () => { + setIsTestingNtfy(true); + try { + await api.post('/settings/ntfy/test'); + toast.success('Test notification sent'); + } catch (error) { + toast.error(getErrorMessage(error, 'Failed to send test notification')); + } finally { + setIsTestingNtfy(false); + } + }; + + const handleClearToken = () => { + setNtfyToken(''); + setTokenCleared(true); + }; + + return ( + + +
+
+
+
+ Integrations + Push notifications via ntfy +
+
+
+ + + {/* Master toggle */} +
+
+

Enable Push Notifications

+

+ Send alerts to your ntfy server for reminders, todos, and events +

+
+ +
+ + {ntfyEnabled && ( + <> + + + {/* Warning banner */} + {isMisconfigured && ( +
+
+ )} + + {/* Connection config */} +
+

Connection

+
+ + setNtfyServerUrl(e.target.value)} + /> +
+
+ + setNtfyTopic(e.target.value)} + /> +
+
+ +
+ { + setNtfyToken(e.target.value); + // If user starts typing after clearing, undo the cleared flag + if (tokenCleared && e.target.value) setTokenCleared(false); + }} + placeholder={ + tokenCleared + ? 'Token will be cleared on save' + : ntfyHasToken + ? '(token saved — leave blank to keep)' + : 'Optional auth token' + } + className="pr-16" + /> +
+ {/* Clear button — only when a token is saved and field is empty and not yet cleared */} + {ntfyHasToken && !ntfyToken && !tokenCleared && ( + + )} + +
+
+ {tokenCleared && ( +

Token will be removed when you save.

+ )} +
+
+ + + + {/* Per-type notification toggles */} +
+

Notification Types

+ + {/* Event reminders */} +
+
+ +

Event reminders

+
+ {eventsEnabled && ( + + )} +
+ + {/* Reminder alerts */} +
+ +

Reminder alerts

+
+ + {/* Todo due dates */} +
+
+ +

Todo due dates

+
+ {todosEnabled && ( + + )} +
+ + {/* Project deadlines */} +
+
+ +

Project deadlines

+
+ {projectsEnabled && ( + + )} +
+
+ + + + {/* Test + Save */} +
+ + +
+ + )} + + {/* Save button when disabled — still persist the toggle state */} + {!ntfyEnabled && ( +
+ +
+ )} + +
+
+ ); +} diff --git a/frontend/src/components/settings/SettingsPage.tsx b/frontend/src/components/settings/SettingsPage.tsx index dfbdcf8..9fa1371 100644 --- a/frontend/src/components/settings/SettingsPage.tsx +++ b/frontend/src/components/settings/SettingsPage.tsx @@ -15,12 +15,13 @@ import { } from 'lucide-react'; import { useSettings } from '@/hooks/useSettings'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; -import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { cn } from '@/lib/utils'; import api from '@/lib/api'; import type { GeoLocation } from '@/types'; +import TotpSetupSection from './TotpSetupSection'; +import NtfySettingsSection from './NtfySettingsSection'; const accentColors = [ { name: 'cyan', label: 'Cyan', color: '#06b6d4' }, @@ -345,9 +346,12 @@ export default function SettingsPage() { - {/* ── Right column: Calendar, Dashboard ── */} + {/* ── Right column: Authentication, Calendar, Dashboard, Integrations ── */}
+ {/* Authentication (TOTP + password change) */} + + {/* Calendar */} @@ -443,6 +447,9 @@ export default function SettingsPage() { + {/* Integrations (ntfy push notifications) */} + +
diff --git a/frontend/src/components/settings/TotpSetupSection.tsx b/frontend/src/components/settings/TotpSetupSection.tsx new file mode 100644 index 0000000..16a732b --- /dev/null +++ b/frontend/src/components/settings/TotpSetupSection.tsx @@ -0,0 +1,539 @@ +import { useState, useEffect } from 'react'; +import { toast } from 'sonner'; +import { + AlertTriangle, + Check, + Copy, + Loader2, + ShieldCheck, +} from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Separator } from '@/components/ui/separator'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, + DialogClose, +} from '@/components/ui/dialog'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import api, { getErrorMessage } from '@/lib/api'; +import type { TotpSetupResponse } from '@/types'; + +type TotpSetupState = 'idle' | 'setup' | 'confirm' | 'backup_codes' | 'enabled'; + +export default function TotpSetupSection() { + // ── Password change state ── + const [passwordForm, setPasswordForm] = useState({ + oldPassword: '', + newPassword: '', + confirmPassword: '', + }); + const [isChangingPassword, setIsChangingPassword] = useState(false); + + // ── TOTP state ── + const [totpSetupState, setTotpSetupState] = useState('idle'); + const [qrCodeBase64, setQrCodeBase64] = useState(''); + const [totpSecret, setTotpSecret] = useState(''); + const [totpConfirmCode, setTotpConfirmCode] = useState(''); + const [backupCodes, setBackupCodes] = useState([]); + const [isTotpSetupPending, setIsTotpSetupPending] = useState(false); + const [isTotpConfirmPending, setIsTotpConfirmPending] = useState(false); + + // ── Disable / Regenerate dialog state ── + const [disableDialogOpen, setDisableDialogOpen] = useState(false); + const [regenDialogOpen, setRegenDialogOpen] = useState(false); + const [dialogPassword, setDialogPassword] = useState(''); + const [dialogCode, setDialogCode] = useState(''); + const [isDialogPending, setIsDialogPending] = useState(false); + + // On mount: check TOTP status to set initial state + useEffect(() => { + api + .get<{ enabled: boolean }>('/auth/totp/status') + .then(({ data }) => { + setTotpSetupState(data.enabled ? 'enabled' : 'idle'); + }) + .catch(() => { + // Endpoint not yet available — default to idle + setTotpSetupState('idle'); + }); + }, []); + + // ── Password change ── + const handlePasswordChange = async () => { + const { oldPassword, newPassword, confirmPassword } = passwordForm; + if (!oldPassword || !newPassword || !confirmPassword) { + toast.error('All password fields are required'); + return; + } + if (newPassword !== confirmPassword) { + toast.error('New passwords do not match'); + return; + } + if (newPassword.length < 12) { + toast.error('Password must be at least 12 characters'); + return; + } + const hasLetter = /[a-zA-Z]/.test(newPassword); + const hasNonLetter = /[^a-zA-Z]/.test(newPassword); + if (!hasLetter || !hasNonLetter) { + toast.error('Password must contain at least one letter and one non-letter character'); + return; + } + setIsChangingPassword(true); + try { + await api.post('/auth/change-password', { + old_password: oldPassword, + new_password: newPassword, + }); + setPasswordForm({ oldPassword: '', newPassword: '', confirmPassword: '' }); + toast.success('Password changed successfully'); + } catch (error) { + toast.error(getErrorMessage(error, 'Failed to change password')); + } finally { + setIsChangingPassword(false); + } + }; + + // ── TOTP setup ── + const handleBeginTotpSetup = async () => { + setIsTotpSetupPending(true); + try { + const { data } = await api.post('/auth/totp/setup'); + setQrCodeBase64(data.qr_code_base64); + setTotpSecret(data.secret); + setBackupCodes(data.backup_codes); + setTotpSetupState('setup'); + } catch (error) { + toast.error(getErrorMessage(error, 'Failed to begin TOTP setup')); + } finally { + setIsTotpSetupPending(false); + } + }; + + const handleTotpConfirm = async () => { + if (!totpConfirmCode || totpConfirmCode.length !== 6) { + toast.error('Enter a 6-digit code'); + return; + } + setIsTotpConfirmPending(true); + try { + const { data } = await api.post<{ backup_codes: string[] }>('/auth/totp/confirm', { + code: totpConfirmCode, + }); + setBackupCodes(data.backup_codes); + setTotpConfirmCode(''); + setTotpSetupState('backup_codes'); + } catch (error) { + toast.error(getErrorMessage(error, 'Invalid code — try again')); + } finally { + setIsTotpConfirmPending(false); + } + }; + + const handleCopyBackupCodes = async () => { + try { + await navigator.clipboard.writeText(backupCodes.join('\n')); + toast.success('Backup codes copied'); + } catch { + toast.error('Failed to copy codes'); + } + }; + + const handleBackupCodesConfirmed = () => { + setBackupCodes([]); + setQrCodeBase64(''); + setTotpSecret(''); + setTotpSetupState('enabled'); + }; + + // ── Disable TOTP ── + const handleDisableConfirm = async () => { + if (!dialogPassword || !dialogCode) { + toast.error('Password and code are required'); + return; + } + setIsDialogPending(true); + try { + await api.post('/auth/totp/disable', { password: dialogPassword, code: dialogCode }); + setDisableDialogOpen(false); + setDialogPassword(''); + setDialogCode(''); + setTotpSetupState('idle'); + toast.success('Two-factor authentication disabled'); + } catch (error) { + toast.error(getErrorMessage(error, 'Failed to disable TOTP')); + } finally { + setIsDialogPending(false); + } + }; + + // ── Regenerate backup codes ── + const handleRegenConfirm = async () => { + if (!dialogPassword || !dialogCode) { + toast.error('Password and code are required'); + return; + } + setIsDialogPending(true); + try { + const { data } = await api.post<{ backup_codes: string[] }>( + '/auth/totp/backup-codes/regenerate', + { password: dialogPassword, code: dialogCode } + ); + setBackupCodes(data.backup_codes); + setRegenDialogOpen(false); + setDialogPassword(''); + setDialogCode(''); + setTotpSetupState('backup_codes'); + toast.success('New backup codes generated'); + } catch (error) { + toast.error(getErrorMessage(error, 'Failed to regenerate backup codes')); + } finally { + setIsDialogPending(false); + } + }; + + const closeDialog = () => { + setDisableDialogOpen(false); + setRegenDialogOpen(false); + setDialogPassword(''); + setDialogCode(''); + }; + + return ( + <> + + +
+
+
+
+ Authentication + Manage your password and two-factor authentication +
+
+
+ + + {/* Subsection A: Change Password */} +
+

Change Password

+
+
+ + setPasswordForm({ ...passwordForm, oldPassword: e.target.value })} + autoComplete="current-password" + /> +
+
+ + setPasswordForm({ ...passwordForm, newPassword: e.target.value })} + autoComplete="new-password" + /> +
+
+ + + setPasswordForm({ ...passwordForm, confirmPassword: e.target.value }) + } + autoComplete="new-password" + /> +
+ +
+
+ + + + {/* Subsection B: TOTP MFA Setup */} +
+ {totpSetupState === 'idle' && ( +
+
+

Two-Factor Authentication

+

+ Add an extra layer of security with an authenticator app +

+
+ +
+ )} + + {totpSetupState === 'setup' && ( +
+

Scan with your authenticator app

+
+ TOTP QR code — scan with your authenticator app +
+

+ Can't scan? Enter this code manually: +

+ + {totpSecret} + + +
+ )} + + {totpSetupState === 'confirm' && ( +
+

Verify your authenticator app

+

+ Enter the 6-digit code shown in your app to confirm setup. +

+
+ + setTotpConfirmCode(e.target.value.replace(/\D/g, ''))} + className="text-center tracking-widest text-lg" + autoFocus + autoComplete="one-time-code" + onKeyDown={(e) => { + if (e.key === 'Enter') handleTotpConfirm(); + }} + /> +
+ + +
+ )} + + {totpSetupState === 'backup_codes' && ( +
+
+
+

+ These {backupCodes.length} codes can each be used once if you lose access to your + authenticator app. Store them somewhere safe — they will not be shown again. +

+
+ {backupCodes.map((code, i) => ( + + {code} + + ))} +
+ + +
+ )} + + {totpSetupState === 'enabled' && ( +
+
+

+ Two-Factor Authentication + + +

+

+ Your account is protected with an authenticator app +

+
+
+ + +
+
+ )} +
+
+
+ + {/* Disable TOTP Dialog */} + + + + + Disable Two-Factor Authentication + + Enter your password and a current authenticator code to disable MFA. + + +
+
+ + setDialogPassword(e.target.value)} + autoComplete="current-password" + /> +
+
+ + setDialogCode(e.target.value.replace(/\D/g, ''))} + className="text-center tracking-widest" + autoComplete="one-time-code" + /> +
+
+ + + + +
+
+ + {/* Regenerate Backup Codes Dialog */} + + + + + Generate New Backup Codes + + Your existing backup codes will be invalidated. Enter your password and a current + authenticator code to continue. + + +
+
+ + setDialogPassword(e.target.value)} + autoComplete="current-password" + /> +
+
+ + setDialogCode(e.target.value.replace(/\D/g, ''))} + className="text-center tracking-widest" + autoComplete="one-time-code" + /> +
+
+ + + + +
+
+ + ); +} diff --git a/frontend/src/hooks/useSettings.ts b/frontend/src/hooks/useSettings.ts index 7000c39..7a5f9c3 100644 --- a/frontend/src/hooks/useSettings.ts +++ b/frontend/src/hooks/useSettings.ts @@ -14,7 +14,9 @@ export function useSettings() { }); const updateMutation = useMutation({ - mutationFn: async (updates: Partial & { preferred_name?: string | null }) => { + mutationFn: async ( + updates: Partial & { preferred_name?: string | null; ntfy_auth_token?: string } + ) => { const { data } = await api.put('/settings', updates); return data; }, @@ -26,7 +28,9 @@ export function useSettings() { return { settings: settingsQuery.data, isLoading: settingsQuery.isLoading, - updateSettings: updateMutation.mutateAsync, + updateSettings: updateMutation.mutateAsync as ( + updates: Partial & { preferred_name?: string | null; ntfy_auth_token?: string } + ) => Promise, isUpdating: updateMutation.isPending, }; }