feat: Enhance task filtering, smart scheduling, audit logs and translations
continuous-integration/drone/push Build is passing
continuous-integration/drone/push Build is passing
This commit is contained in:
+268
-10
@@ -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 */}
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user