Stage 6 Phase 4-5: TOTP setup UI and ntfy integrations settings
Adds Authentication card (password change + TOTP 5-state setup flow) and Integrations card (ntfy master toggle, connection config, per-type toggles, test button) to SettingsPage right column in correct order. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
fbc452a004
commit
6ad6056125
356
frontend/src/components/settings/NtfySettingsSection.tsx
Normal file
356
frontend/src/components/settings/NtfySettingsSection.tsx
Normal file
@ -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<Settings> & { preferred_name?: string | null; ntfy_auth_token?: string }
|
||||
) => Promise<Settings>;
|
||||
}
|
||||
|
||||
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<Settings> & { 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 (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-1.5 rounded-md bg-orange-500/10">
|
||||
<Bell className="h-4 w-4 text-orange-400" aria-hidden="true" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle>Integrations</CardTitle>
|
||||
<CardDescription>Push notifications via ntfy</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-5">
|
||||
|
||||
{/* Master toggle */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium">Enable Push Notifications</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Send alerts to your ntfy server for reminders, todos, and events
|
||||
</p>
|
||||
</div>
|
||||
<Switch checked={ntfyEnabled} onCheckedChange={setNtfyEnabled} />
|
||||
</div>
|
||||
|
||||
{ntfyEnabled && (
|
||||
<>
|
||||
<Separator />
|
||||
|
||||
{/* Warning banner */}
|
||||
{isMisconfigured && (
|
||||
<div
|
||||
role="alert"
|
||||
className="flex items-center gap-2 rounded-md border border-amber-500/30 bg-amber-500/10 px-3 py-2"
|
||||
>
|
||||
<AlertTriangle className="h-4 w-4 text-amber-400 shrink-0" aria-hidden="true" />
|
||||
<p className="text-xs text-amber-400">
|
||||
Server URL and topic are required to send notifications
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Connection config */}
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm font-medium">Connection</p>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="ntfy_server_url">Server URL</Label>
|
||||
<Input
|
||||
id="ntfy_server_url"
|
||||
type="text"
|
||||
placeholder="https://ntfy.sh"
|
||||
value={ntfyServerUrl}
|
||||
onChange={(e) => setNtfyServerUrl(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="ntfy_topic">Topic</Label>
|
||||
<Input
|
||||
id="ntfy_topic"
|
||||
type="text"
|
||||
placeholder="my-umbra-alerts"
|
||||
value={ntfyTopic}
|
||||
onChange={(e) => setNtfyTopic(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="ntfy_token">Auth Token</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="ntfy_token"
|
||||
type={showToken ? 'text' : 'password'}
|
||||
value={ntfyToken}
|
||||
onChange={(e) => {
|
||||
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"
|
||||
/>
|
||||
<div className="absolute right-2 top-1/2 -translate-y-1/2 flex gap-1 items-center">
|
||||
{/* Clear button — only when a token is saved and field is empty and not yet cleared */}
|
||||
{ntfyHasToken && !ntfyToken && !tokenCleared && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClearToken}
|
||||
className="text-muted-foreground hover:text-foreground transition-colors p-0.5"
|
||||
aria-label="Clear saved token"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowToken(!showToken)}
|
||||
className="text-muted-foreground hover:text-foreground transition-colors p-0.5"
|
||||
aria-label={showToken ? 'Hide auth token' : 'Show auth token'}
|
||||
>
|
||||
{showToken ? (
|
||||
<EyeOff className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Eye className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{tokenCleared && (
|
||||
<p className="text-xs text-amber-400">Token will be removed when you save.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Per-type notification toggles */}
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm font-medium">Notification Types</p>
|
||||
|
||||
{/* Event reminders */}
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||
<Switch checked={eventsEnabled} onCheckedChange={setEventsEnabled} />
|
||||
<p className="text-sm">Event reminders</p>
|
||||
</div>
|
||||
{eventsEnabled && (
|
||||
<Select
|
||||
value={String(eventLeadMinutes)}
|
||||
onChange={(e) => setEventLeadMinutes(Number(e.target.value))}
|
||||
className="h-8 w-36 text-xs"
|
||||
>
|
||||
<option value="5">5 min before</option>
|
||||
<option value="10">10 min before</option>
|
||||
<option value="15">15 min before</option>
|
||||
<option value="30">30 min before</option>
|
||||
<option value="60">1 hour before</option>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Reminder alerts */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch checked={remindersEnabled} onCheckedChange={setRemindersEnabled} />
|
||||
<p className="text-sm">Reminder alerts</p>
|
||||
</div>
|
||||
|
||||
{/* Todo due dates */}
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||
<Switch checked={todosEnabled} onCheckedChange={setTodosEnabled} />
|
||||
<p className="text-sm">Todo due dates</p>
|
||||
</div>
|
||||
{todosEnabled && (
|
||||
<Select
|
||||
value={String(todoLeadDays)}
|
||||
onChange={(e) => setTodoLeadDays(Number(e.target.value))}
|
||||
className="h-8 w-36 text-xs"
|
||||
>
|
||||
<option value="0">Same day</option>
|
||||
<option value="1">1 day before</option>
|
||||
<option value="2">2 days before</option>
|
||||
<option value="3">3 days before</option>
|
||||
<option value="7">1 week before</option>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Project deadlines */}
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||
<Switch checked={projectsEnabled} onCheckedChange={setProjectsEnabled} />
|
||||
<p className="text-sm">Project deadlines</p>
|
||||
</div>
|
||||
{projectsEnabled && (
|
||||
<Select
|
||||
value={String(projectLeadDays)}
|
||||
onChange={(e) => setProjectLeadDays(Number(e.target.value))}
|
||||
className="h-8 w-36 text-xs"
|
||||
>
|
||||
<option value="0">Same day</option>
|
||||
<option value="1">1 day before</option>
|
||||
<option value="2">2 days before</option>
|
||||
<option value="3">3 days before</option>
|
||||
<option value="7">1 week before</option>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Test + Save */}
|
||||
<div className="flex items-center justify-between flex-wrap gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleNtfyTest}
|
||||
disabled={!ntfyEnabled || !ntfyServerUrl.trim() || !ntfyTopic.trim() || isTestingNtfy}
|
||||
className="gap-2"
|
||||
>
|
||||
{isTestingNtfy ? (
|
||||
<><Loader2 className="h-4 w-4 animate-spin" />Sending...</>
|
||||
) : (
|
||||
<><Send className="h-4 w-4" />Send Test Notification</>
|
||||
)}
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleSave} disabled={isSaving} className="gap-2">
|
||||
{isSaving ? (
|
||||
<><Loader2 className="h-4 w-4 animate-spin" />Saving</>
|
||||
) : (
|
||||
'Save Notifications Config'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Save button when disabled — still persist the toggle state */}
|
||||
{!ntfyEnabled && (
|
||||
<div className="flex justify-end">
|
||||
<Button size="sm" onClick={handleSave} disabled={isSaving} className="gap-2">
|
||||
{isSaving ? <><Loader2 className="h-4 w-4 animate-spin" />Saving</> : 'Save'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@ -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() {
|
||||
|
||||
</div>
|
||||
|
||||
{/* ── Right column: Calendar, Dashboard ── */}
|
||||
{/* ── Right column: Authentication, Calendar, Dashboard, Integrations ── */}
|
||||
<div className="space-y-6">
|
||||
|
||||
{/* Authentication (TOTP + password change) */}
|
||||
<TotpSetupSection />
|
||||
|
||||
{/* Calendar */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
@ -443,6 +447,9 @@ export default function SettingsPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Integrations (ntfy push notifications) */}
|
||||
<NtfySettingsSection settings={settings} updateSettings={updateSettings} />
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
539
frontend/src/components/settings/TotpSetupSection.tsx
Normal file
539
frontend/src/components/settings/TotpSetupSection.tsx
Normal file
@ -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<TotpSetupState>('idle');
|
||||
const [qrCodeBase64, setQrCodeBase64] = useState('');
|
||||
const [totpSecret, setTotpSecret] = useState('');
|
||||
const [totpConfirmCode, setTotpConfirmCode] = useState('');
|
||||
const [backupCodes, setBackupCodes] = useState<string[]>([]);
|
||||
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<TotpSetupResponse>('/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 (
|
||||
<>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-1.5 rounded-md bg-red-500/10">
|
||||
<ShieldCheck className="h-4 w-4 text-red-400" aria-hidden="true" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle>Authentication</CardTitle>
|
||||
<CardDescription>Manage your password and two-factor authentication</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
|
||||
{/* Subsection A: Change Password */}
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm font-medium">Change Password</p>
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="old_password">Current Password</Label>
|
||||
<Input
|
||||
id="old_password"
|
||||
type="password"
|
||||
value={passwordForm.oldPassword}
|
||||
onChange={(e) => setPasswordForm({ ...passwordForm, oldPassword: e.target.value })}
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="new_password">New Password</Label>
|
||||
<Input
|
||||
id="new_password"
|
||||
type="password"
|
||||
value={passwordForm.newPassword}
|
||||
onChange={(e) => setPasswordForm({ ...passwordForm, newPassword: e.target.value })}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirm_password">Confirm New Password</Label>
|
||||
<Input
|
||||
id="confirm_password"
|
||||
type="password"
|
||||
value={passwordForm.confirmPassword}
|
||||
onChange={(e) =>
|
||||
setPasswordForm({ ...passwordForm, confirmPassword: e.target.value })
|
||||
}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handlePasswordChange}
|
||||
disabled={isChangingPassword}
|
||||
size="sm"
|
||||
>
|
||||
{isChangingPassword ? (
|
||||
<><Loader2 className="h-4 w-4 animate-spin" />Saving</>
|
||||
) : (
|
||||
'Change Password'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Subsection B: TOTP MFA Setup */}
|
||||
<div className="space-y-4">
|
||||
{totpSetupState === 'idle' && (
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium">Two-Factor Authentication</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Add an extra layer of security with an authenticator app
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleBeginTotpSetup}
|
||||
disabled={isTotpSetupPending}
|
||||
>
|
||||
{isTotpSetupPending ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Enable MFA'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{totpSetupState === 'setup' && (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm font-medium">Scan with your authenticator app</p>
|
||||
<div className="flex justify-center">
|
||||
<img
|
||||
src={`data:image/png;base64,${qrCodeBase64}`}
|
||||
alt="TOTP QR code — scan with your authenticator app"
|
||||
className="h-40 w-40 rounded-md border border-border"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground text-center">
|
||||
Can't scan? Enter this code manually:
|
||||
</p>
|
||||
<code className="block text-center text-xs font-mono bg-secondary px-3 py-2 rounded-md tracking-widest break-all">
|
||||
{totpSecret}
|
||||
</code>
|
||||
<Button className="w-full" onClick={() => setTotpSetupState('confirm')}>
|
||||
Next: Verify Code
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{totpSetupState === 'confirm' && (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm font-medium">Verify your authenticator app</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Enter the 6-digit code shown in your app to confirm setup.
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="totp-confirm-code">Verification Code</Label>
|
||||
<Input
|
||||
id="totp-confirm-code"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
maxLength={6}
|
||||
placeholder="000000"
|
||||
value={totpConfirmCode}
|
||||
onChange={(e) => 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();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={handleTotpConfirm}
|
||||
disabled={isTotpConfirmPending}
|
||||
>
|
||||
{isTotpConfirmPending ? (
|
||||
<><Loader2 className="h-4 w-4 animate-spin" />Verifying</>
|
||||
) : (
|
||||
'Verify & Enable'
|
||||
)}
|
||||
</Button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTotpSetupState('setup')}
|
||||
className="w-full text-center text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
Back to QR code
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{totpSetupState === 'backup_codes' && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertTriangle className="h-4 w-4 text-amber-400" aria-hidden="true" />
|
||||
<p className="text-sm font-medium text-amber-400">Save these backup codes now</p>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
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.
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-2 bg-secondary rounded-md p-3">
|
||||
{backupCodes.map((code, i) => (
|
||||
<code key={i} className="text-xs font-mono text-foreground text-center py-0.5">
|
||||
{code}
|
||||
</code>
|
||||
))}
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleCopyBackupCodes}
|
||||
className="w-full gap-2"
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
Copy All Codes
|
||||
</Button>
|
||||
<Button className="w-full" onClick={handleBackupCodesConfirmed}>
|
||||
I've saved my backup codes
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{totpSetupState === 'enabled' && (
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium flex items-center gap-2 flex-wrap">
|
||||
Two-Factor Authentication
|
||||
<span className="inline-flex items-center gap-1 text-[10px] px-1.5 py-0.5 rounded-full bg-green-500/10 text-green-400 font-medium uppercase tracking-wide">
|
||||
<Check className="h-3 w-3" aria-hidden="true" />
|
||||
Enabled
|
||||
</span>
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Your account is protected with an authenticator app
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2 shrink-0">
|
||||
<Button variant="outline" size="sm" onClick={() => setRegenDialogOpen(true)}>
|
||||
New backup codes
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => setDisableDialogOpen(true)}
|
||||
className="bg-destructive/10 text-destructive hover:bg-destructive/20 border-destructive/30"
|
||||
>
|
||||
Disable
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Disable TOTP Dialog */}
|
||||
<Dialog open={disableDialogOpen} onOpenChange={closeDialog}>
|
||||
<DialogContent>
|
||||
<DialogClose onClick={closeDialog} />
|
||||
<DialogHeader>
|
||||
<DialogTitle>Disable Two-Factor Authentication</DialogTitle>
|
||||
<DialogDescription>
|
||||
Enter your password and a current authenticator code to disable MFA.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="disable-password">Password</Label>
|
||||
<Input
|
||||
id="disable-password"
|
||||
type="password"
|
||||
value={dialogPassword}
|
||||
onChange={(e) => setDialogPassword(e.target.value)}
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="disable-code">Authenticator Code</Label>
|
||||
<Input
|
||||
id="disable-code"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
maxLength={6}
|
||||
placeholder="000000"
|
||||
value={dialogCode}
|
||||
onChange={(e) => setDialogCode(e.target.value.replace(/\D/g, ''))}
|
||||
className="text-center tracking-widest"
|
||||
autoComplete="one-time-code"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" size="sm" onClick={closeDialog} disabled={isDialogPending}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={handleDisableConfirm}
|
||||
disabled={isDialogPending}
|
||||
className="gap-2"
|
||||
>
|
||||
{isDialogPending ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
|
||||
Disable MFA
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Regenerate Backup Codes Dialog */}
|
||||
<Dialog open={regenDialogOpen} onOpenChange={closeDialog}>
|
||||
<DialogContent>
|
||||
<DialogClose onClick={closeDialog} />
|
||||
<DialogHeader>
|
||||
<DialogTitle>Generate New Backup Codes</DialogTitle>
|
||||
<DialogDescription>
|
||||
Your existing backup codes will be invalidated. Enter your password and a current
|
||||
authenticator code to continue.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="regen-password">Password</Label>
|
||||
<Input
|
||||
id="regen-password"
|
||||
type="password"
|
||||
value={dialogPassword}
|
||||
onChange={(e) => setDialogPassword(e.target.value)}
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="regen-code">Authenticator Code</Label>
|
||||
<Input
|
||||
id="regen-code"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
maxLength={6}
|
||||
placeholder="000000"
|
||||
value={dialogCode}
|
||||
onChange={(e) => setDialogCode(e.target.value.replace(/\D/g, ''))}
|
||||
className="text-center tracking-widest"
|
||||
autoComplete="one-time-code"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" size="sm" onClick={closeDialog} disabled={isDialogPending}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleRegenConfirm}
|
||||
disabled={isDialogPending}
|
||||
className="gap-2"
|
||||
>
|
||||
{isDialogPending ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
|
||||
Generate New Codes
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -14,7 +14,9 @@ export function useSettings() {
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: async (updates: Partial<Settings> & { preferred_name?: string | null }) => {
|
||||
mutationFn: async (
|
||||
updates: Partial<Settings> & { preferred_name?: string | null; ntfy_auth_token?: string }
|
||||
) => {
|
||||
const { data } = await api.put<Settings>('/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<Settings> & { preferred_name?: string | null; ntfy_auth_token?: string }
|
||||
) => Promise<Settings>,
|
||||
isUpdating: updateMutation.isPending,
|
||||
};
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user