feat: Enhance task filtering, smart scheduling, audit logs and translations
continuous-integration/drone/push Build is passing

This commit is contained in:
2025-12-17 14:26:54 +01:00
parent 9819d8db0b
commit 2579df0b89
32 changed files with 2219 additions and 456 deletions
+268 -10
View File
@@ -6,7 +6,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
import { Globe, Tag, Plus, Edit, Trash2, Layers, User as UserIcon, ShieldCheck, Share2, Server, Copy, Settings as SettingsIcon, Bell, Loader2 } from 'lucide-react';
import { Globe, Tag, Plus, Edit, Trash2, Layers, User as UserIcon, ShieldCheck, Share2, Server, Copy, Settings as SettingsIcon, Bell, Loader2, Clock } from 'lucide-react';
import { Switch } from '@/components/ui/switch';
import { ShareAccessModal } from '@/components/ShareAccessModal';
import { ChangePasswordModal } from '@/components/ChangePasswordModal';
@@ -18,7 +18,7 @@ import { queryClient, apiRequest } from '@/lib/queryClient';
import { useToast } from '@/hooks/use-toast';
import { useLocation } from "wouter";
import { useNotifications } from '@/hooks/use-notifications';
// import { DataExportCard } from '@/components/user/DataExportCard';
import { DataExportCard } from '@/components/user/DataExportCard';
const NotificationSettings = () => {
const { t } = useTranslation();
@@ -52,6 +52,137 @@ const NotificationSettings = () => {
);
};
const ScheduleSettings = ({ user }: { user: User }) => {
const { t } = useTranslation();
const { toast } = useToast();
const [activeTab, setActiveTab] = useState<'work' | 'personal'>('work');
// Helper to safely get availability data
const getAvailability = (type: 'work' | 'personal') => {
// Cast to any because TS might not know about the JSON structure fully yet if types aren't perfectly synced in IDE
const avail = user.availability as any;
if (avail && avail[type]) {
return avail[type];
}
// Fallback defaults
if (type === 'work') return user.workHours || { start: "09:00", end: "17:00", days: [1, 2, 3, 4, 5] };
return { start: "18:00", end: "22:00", days: [1, 2, 3, 4, 5, 0, 6] };
};
// State for both schedules
const [workSchedule, setWorkSchedule] = useState(getAvailability('work'));
const [personalSchedule, setPersonalSchedule] = useState(getAvailability('personal'));
const currentSchedule = activeTab === 'work' ? workSchedule : personalSchedule;
const setCurrentSchedule = (newSched: any) => {
if (activeTab === 'work') setWorkSchedule(newSched);
else setPersonalSchedule(newSched);
};
const updateScheduleMutation = useMutation({
mutationFn: async () => {
const payload = {
availability: {
work: workSchedule,
personal: personalSchedule
}
};
const res = await apiRequest("PATCH", "/api/user/schedule", payload);
return res.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["/api/user"] });
toast({ title: t('settings.schedule.saved') });
}
});
const toggleDay = (day: number) => {
const currentDays = currentSchedule.days || [];
let newDays;
if (currentDays.includes(day)) {
newDays = currentDays.filter((d: number) => d !== day);
} else {
newDays = [...currentDays, day].sort();
}
setCurrentSchedule({ ...currentSchedule, days: newDays });
};
const handleChange = (field: 'start' | 'end', value: string) => {
setCurrentSchedule({ ...currentSchedule, [field]: value });
};
const handleSave = () => {
updateScheduleMutation.mutate();
};
const days = [
{ id: 1, label: t('analytics.mon') },
{ id: 2, label: t('analytics.tue') },
{ id: 3, label: t('analytics.wed') },
{ id: 4, label: t('analytics.thu') },
{ id: 5, label: t('analytics.fri') },
{ id: 6, label: t('analytics.sat') },
{ id: 0, label: t('analytics.sun') },
];
return (
<div className="space-y-6">
<div className="flex space-x-4 border-b">
<button
className={`py-2 text-sm font-medium border-b-2 transition-colors ${activeTab === 'work' ? 'border-primary text-primary' : 'border-transparent text-muted-foreground hover:text-foreground'}`}
onClick={() => setActiveTab('work')}
>
{t('settings.schedule.work', 'Work Schedule')}
</button>
<button
className={`py-2 text-sm font-medium border-b-2 transition-colors ${activeTab === 'personal' ? 'border-primary text-primary' : 'border-transparent text-muted-foreground hover:text-foreground'}`}
onClick={() => setActiveTab('personal')}
>
{t('settings.schedule.personal', 'Personal Schedule')}
</button>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<label className="text-sm font-medium">{t('settings.schedule.start')}</label>
<Input type="time" value={currentSchedule.start} onChange={(e) => handleChange('start', e.target.value)} />
</div>
<div className="space-y-2">
<label className="text-sm font-medium">{t('settings.schedule.end')}</label>
<Input type="time" value={currentSchedule.end} onChange={(e) => handleChange('end', e.target.value)} />
</div>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">{t('settings.schedule.days')}</label>
<div className="flex flex-wrap gap-2">
{days.map(day => (
<Button
key={day.id}
variant={currentSchedule.days?.includes(day.id) ? "default" : "outline"}
size="sm"
onClick={() => toggleDay(day.id)}
className="w-12 h-12 rounded-full p-0"
>
{day.label.slice(0, 2)}
</Button>
))}
</div>
</div>
<div className="bg-muted/50 p-4 rounded-md text-sm text-muted-foreground">
{activeTab === 'work'
? t('settings.schedule.workDesc', "Tasks with 'Work' labels will be scheduled during these hours.")
: t('settings.schedule.personalDesc', "Tasks with 'Personal' labels will be scheduled during these hours. 'Neutral' tasks can use either.")}
</div>
<Button onClick={handleSave} disabled={updateScheduleMutation.isPending}>
{updateScheduleMutation.isPending ? t('common.loading') : t('common.save')}
</Button>
</div>
);
};
interface SettingsProps {
onNavigateToTemplates: () => void;
}
@@ -89,6 +220,7 @@ export default function Settings({ onNavigateToTemplates }: SettingsProps) {
const [editingLabel, setEditingLabel] = useState<Label | null>(null);
const [labelName, setLabelName] = useState('');
const [labelColor, setLabelColor] = useState('#3B82F6');
const [labelDomain, setLabelDomain] = useState('neutral');
const [isShareAccessOpen, setIsShareAccessOpen] = useState(false);
const [isShareLabelOpen, setIsShareLabelOpen] = useState(false);
const [sharingLabel, setSharingLabel] = useState<Label | null>(null);
@@ -118,6 +250,63 @@ export default function Settings({ onNavigateToTemplates }: SettingsProps) {
privacyMutation.mutate(updates);
};
// 2FA Logic
const [is2FADialogOpen, setIs2FADialogOpen] = useState(false);
const [otpCode, setOtpCode] = useState("");
const [debugCode, setDebugCode] = useState<string | null>(null);
const generate2FAMutation = useMutation({
mutationFn: async () => {
const res = await apiRequest("POST", "/api/auth/2fa/generate");
return res.json();
},
onSuccess: (data) => {
setDebugCode(data.debugCode); // For dev convenience
setIs2FADialogOpen(true);
toast({ title: t('auth.2faCodeSent'), description: t('auth.checkEmail') });
},
onError: (err: Error) => {
toast({ title: "Failed to start 2FA setup", description: err.message, variant: "destructive" });
}
});
const verify2FAMutation = useMutation({
mutationFn: async (code: string) => {
const res = await apiRequest("POST", "/api/auth/verify-2fa", { userId: user.id, code });
return res.json();
},
onSuccess: () => {
setIs2FADialogOpen(false);
setOtpCode("");
queryClient.invalidateQueries({ queryKey: ["/api/user"] });
toast({ title: "2FA Enabled Successfully" });
},
onError: (err: Error) => {
toast({ title: "Verification failed", description: err.message, variant: "destructive" });
}
});
const disable2FAMutation = useMutation({
mutationFn: async () => {
await apiRequest("POST", "/api/auth/2fa/disable");
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["/api/user"] });
toast({ title: "2FA Disabled" });
}
});
const handle2FAToggle = (checked: boolean) => {
if (checked) {
generate2FAMutation.mutate(); // Starts flow, opens dialog on success
} else {
if (confirm("Are you sure you want to disable 2FA? This will reduce your account security.")) {
disable2FAMutation.mutate();
}
}
};
// Fetch labels
const { data: labelsData, isLoading: labelsLoading } = useQuery<Label[]>({
queryKey: ['/api/labels']
@@ -126,13 +315,14 @@ export default function Settings({ onNavigateToTemplates }: SettingsProps) {
// Create label mutation
const createLabelMutation = useMutation({
mutationFn: (data: { name: string; color: string }) =>
mutationFn: (data: { name: string; color: string; domain: string }) =>
apiRequest('POST', '/api/labels', data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['/api/labels'] });
setIsLabelDialogOpen(false);
setLabelName('');
setLabelColor('#3B82F6');
setLabelDomain('neutral');
toast({
title: t('settings.labels.created'),
description: t('settings.labels.createdDescription'),
@@ -142,7 +332,7 @@ export default function Settings({ onNavigateToTemplates }: SettingsProps) {
// Update label mutation
const updateLabelMutation = useMutation({
mutationFn: ({ id, ...data }: { id: string; name: string; color: string }) =>
mutationFn: ({ id, ...data }: { id: string; name: string; color: string; domain: string }) =>
apiRequest('PATCH', `/api/labels/${id}`, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['/api/labels'] });
@@ -150,6 +340,7 @@ export default function Settings({ onNavigateToTemplates }: SettingsProps) {
setEditingLabel(null);
setLabelName('');
setLabelColor('#3B82F6');
setLabelDomain('neutral');
toast({
title: t('settings.labels.updated'),
description: t('settings.labels.updatedDescription'),
@@ -177,9 +368,10 @@ export default function Settings({ onNavigateToTemplates }: SettingsProps) {
id: editingLabel.id,
name: labelName,
color: labelColor,
domain: labelDomain,
});
} else {
createLabelMutation.mutate({ name: labelName, color: labelColor });
createLabelMutation.mutate({ name: labelName, color: labelColor, domain: labelDomain });
}
};
@@ -187,6 +379,7 @@ export default function Settings({ onNavigateToTemplates }: SettingsProps) {
setEditingLabel(label);
setLabelName(label.name);
setLabelColor(label.color);
setLabelDomain(label.domain || 'neutral');
setIsLabelDialogOpen(true);
};
@@ -349,9 +542,39 @@ export default function Settings({ onNavigateToTemplates }: SettingsProps) {
</div>
<Switch
checked={!!user?.is2faEnabled}
onCheckedChange={(checked) => handlePrivacyUpdate({ is2faEnabled: checked } as any)}
onCheckedChange={(checked) => handle2FAToggle(checked)}
disabled={generate2FAMutation.isPending || disable2FAMutation.isPending}
/>
</div>
<Dialog open={is2FADialogOpen} onOpenChange={setIs2FADialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('auth.verify2FATitle', 'Verify 2FA')}</DialogTitle>
<CardDescription>
Enter the code sent to your email to enable 2FA.
{debugCode && <div className="mt-2 p-2 bg-muted rounded text-xs font-mono">Debug Code: {debugCode}</div>}
</CardDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<Input
placeholder="123456"
className="text-center text-2xl tracking-widest"
maxLength={6}
value={otpCode}
onChange={(e) => setOtpCode(e.target.value.replace(/\D/g, ''))}
/>
<Button
className="w-full"
onClick={() => verify2FAMutation.mutate(otpCode)}
disabled={verify2FAMutation.isPending || otpCode.length !== 6}
>
{verify2FAMutation.isPending ? "Verifying..." : "Verify & Enable"}
</Button>
</div>
</DialogContent>
</Dialog>
<div className="pt-2">
<Button variant="outline" onClick={() => setIsShareAccessOpen(true)}>
<Share2 className="w-4 h-4 mr-2" />
@@ -363,6 +586,22 @@ export default function Settings({ onNavigateToTemplates }: SettingsProps) {
<ShareAccessModal open={isShareAccessOpen} onOpenChange={setIsShareAccessOpen} />
{/* Schedule Settings */}
<Card data-testid="card-schedule-settings">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Clock className="w-5 h-5" />
{t('settings.schedule.title')}
</CardTitle>
<CardDescription>
{t('settings.schedule.description')}
</CardDescription>
</CardHeader>
<CardContent>
{user && <ScheduleSettings user={user} />}
</CardContent>
</Card>
@@ -448,6 +687,19 @@ export default function Settings({ onNavigateToTemplates }: SettingsProps) {
/>
</div>
</div>
<div>
<Select value={labelDomain} onValueChange={setLabelDomain}>
<SelectTrigger>
<SelectValue placeholder="Context (Domain)" />
</SelectTrigger>
<SelectContent>
<SelectItem value="work">Work</SelectItem>
<SelectItem value="personal">Personal</SelectItem>
<SelectItem value="neutral">Neutral</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground mt-1">Used for smart scheduling.</p>
</div>
<div className="flex gap-3 pt-2">
<Button
variant="outline"
@@ -456,6 +708,7 @@ export default function Settings({ onNavigateToTemplates }: SettingsProps) {
setEditingLabel(null);
setLabelName('');
setLabelColor('#3B82F6');
setLabelDomain('neutral');
}}
className="flex-1"
data-testid="button-cancel-label"
@@ -496,9 +749,14 @@ export default function Settings({ onNavigateToTemplates }: SettingsProps) {
className="w-4 h-4 rounded"
style={{ backgroundColor: label.color }}
/>
<span className="font-medium text-sm" data-testid={`text-label-name-${label.id}`}>
{label.name}
</span>
<div className="flex flex-col">
<span className="font-medium text-sm" data-testid={`text-label-name-${label.id}`}>
{label.name}
</span>
<span className="text-xs text-muted-foreground capitalize">
{label.domain || 'neutral'}
</span>
</div>
</div>
<div className="flex items-center gap-1">
{label.creatorId === user?.id && (
@@ -586,7 +844,7 @@ export default function Settings({ onNavigateToTemplates }: SettingsProps) {
</Card>
{/* Data Export */}
{/* <DataExportCard user={user} /> */}
<DataExportCard user={user} />
{/* Admin Section */}
{