feat: объединение страниц профиля — /profile рендерит карточку пользователя

- /profile теперь обёртка MyProfilePage над UserProfilePage (один экран для своего и чужого профиля)
- self-вкладки из ProfileSettings перенесены: Уведомления, Offline, Интерфейс, Безопасность (components/profile/*)
- ProfileSettings.tsx удалён
- админ видит вкладки Уведомления и Offline на чужой карточке с данными целевого пользователя
  (userId-параметр в GET/POST /api/subscriptions, PATCH/DELETE по владельцу, GET /api/sync/config?userId=; проверка canManageUser)
- Роль/Активен/Статус disabled для не-админа (сервер их и раньше игнорировал)
- пункт меню «Настройки профиля» переименован в «Мой профиль»
This commit is contained in:
2026-08-05 11:48:07 +03:00
parent 667bddeac9
commit 47869971e6
13 changed files with 1089 additions and 971 deletions

View File

@@ -42,7 +42,7 @@ const TableEditor = safeLazy(() => import("@/pages/TableEditor"));
const TaskView = safeLazy(() => import("@/pages/TaskView")); const TaskView = safeLazy(() => import("@/pages/TaskView"));
const TaskDetail = safeLazy(() => import("@/pages/TaskDetail")); const TaskDetail = safeLazy(() => import("@/pages/TaskDetail"));
const Settings = safeLazy(() => import("@/pages/Settings")); const Settings = safeLazy(() => import("@/pages/Settings"));
const ProfileSettings = safeLazy(() => import("@/pages/ProfileSettings")); const MyProfilePage = safeLazy(() => import("@/pages/MyProfilePage"));
const Reports = safeLazy(() => import("@/pages/Reports")); const Reports = safeLazy(() => import("@/pages/Reports"));
const Projects = safeLazy(() => import("@/pages/Projects")); const Projects = safeLazy(() => import("@/pages/Projects"));
const Bots = safeLazy(() => import("@/pages/Bots")); const Bots = safeLazy(() => import("@/pages/Bots"));
@@ -159,7 +159,7 @@ function Router() { return (
<Route path="/projects" component={Projects} /> <Route path="/projects" component={Projects} />
<Route path="/reports" component={Reports} /> <Route path="/reports" component={Reports} />
<Route path="/settings" component={Settings} /> <Route path="/settings" component={Settings} />
<Route path="/profile" component={ProfileSettings} /> <Route path="/profile" component={MyProfilePage} />
<Route path="/bots" component={Bots} /> <Route path="/bots" component={Bots} />
<Route path="/field-templates" component={FieldTemplates} /> <Route path="/field-templates" component={FieldTemplates} />
<Route path="/global-fields"> <Route path="/global-fields">

View File

@@ -1619,7 +1619,7 @@ export function Sidebar({ children }: SidebarProps) {
<Link href="/profile"> <Link href="/profile">
<DropdownMenuItem className="text-xs" data-testid="item-profile-settings"> <DropdownMenuItem className="text-xs" data-testid="item-profile-settings">
<User className="w-3.5 h-3.5 mr-2" /> <User className="w-3.5 h-3.5 mr-2" />
Настройки профиля Мой профиль
</DropdownMenuItem> </DropdownMenuItem>
</Link> </Link>
<DropdownMenuSeparator /> <DropdownMenuSeparator />

View File

@@ -0,0 +1,92 @@
import { Card, CardContent } from '@/components/ui/card';
import { Switch } from '@/components/ui/switch';
import { Button } from '@/components/ui/button';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { useTheme } from '@/hooks/use-theme';
import { useSkin } from '@/themes/ThemeProvider';
import { usePwaFontSize } from '@/hooks/usePwaFontSize';
import { Moon, Sun, Type, Palette } from 'lucide-react';
/**
* Вкладка «Интерфейс» единого экрана профиля: тема, скин, размер шрифта в PWA.
* Настройки персональные, поэтому вкладка рендерится только для своего профиля.
*/
export function ProfileInterfaceTab({ userId }: { userId: number }) {
const { theme, toggleTheme } = useTheme();
const { skinId, setSkinId, availableSkins } = useSkin();
const { isPwa, fontSize, increase: increaseFontSize, decrease: decreaseFontSize } = usePwaFontSize(userId);
return (
<div className="space-y-4">
<Card>
<CardContent className="flex items-center justify-between py-4">
<div className="flex items-center gap-2">
{theme === 'dark' ? (
<Moon className="w-4 h-4 text-muted-foreground" />
) : (
<Sun className="w-4 h-4 text-muted-foreground" />
)}
<span className="font-medium">Тема</span>
</div>
<Switch
checked={theme === 'dark'}
onCheckedChange={toggleTheme}
/>
</CardContent>
</Card>
<Card>
<CardContent className="flex items-center justify-between py-4">
<div className="flex items-center gap-2">
<Palette className="w-4 h-4 text-muted-foreground" />
<span className="font-medium">Скин интерфейса</span>
</div>
<Select value={skinId} onValueChange={setSkinId}>
<SelectTrigger className="w-[180px]" data-testid="select-skin">
<SelectValue placeholder="Выберите скин" />
</SelectTrigger>
<SelectContent>
{availableSkins.map(skin => (
<SelectItem key={skin.id} value={skin.id}>
{skin.name}
</SelectItem>
))}
</SelectContent>
</Select>
</CardContent>
</Card>
{isPwa && (
<Card>
<CardContent className="flex items-center justify-between py-4">
<div className="flex items-center gap-2">
<Type className="w-4 h-4 text-muted-foreground" />
<span className="font-medium">Шрифт</span>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="icon"
className="h-7 w-7 text-xs"
onClick={decreaseFontSize}
disabled={fontSize <= 12}
>
−
</Button>
<span className="text-sm w-8 text-center tabular-nums">{fontSize}</span>
<Button
variant="outline"
size="icon"
className="h-7 w-7 text-xs"
onClick={increaseFontSize}
disabled={fontSize >= 24}
>
+
</Button>
</div>
</CardContent>
</Card>
)}
</div>
);
}

View File

@@ -0,0 +1,474 @@
import { useState } from 'react';
import { useQuery, useMutation } from '@tanstack/react-query';
import { queryClient, apiRequest } from '@/lib/queryClient';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Switch } from '@/components/ui/switch';
import { Label } from '@/components/ui/label';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { useToast } from '@/hooks/use-toast';
import { useAuth } from '@/hooks/useAuth';
import { Bell, Mail, Plus, Trash2, Loader2, Smartphone } from 'lucide-react';
type NotificationEventType = {
id: number;
code: string;
name: string;
description: string | null;
category: string;
isActive: boolean;
};
type NotificationSubscription = {
id: number;
userId: number;
organizationId: number;
eventTypeCode: string;
targetType: string | null;
targetId: number | null;
channels: string[];
isActive: boolean;
};
type Form = {
id: number;
name: string;
};
/**
* Вкладка «Уведомления» единого экрана профиля.
* При передаче userId чужого пользователя (админский просмотр) запросы идут с ?userId= —
* бэкенд проверяет canManageUser. Без userId — свои подписки.
*/
export function ProfileNotificationsTab({ userId }: { userId?: number }) {
const { toast } = useToast();
const { user: currentUser } = useAuth();
const [selectedEventType, setSelectedEventType] = useState<string>('');
const [selectedTargetType, setSelectedTargetType] = useState<string>('all');
const [selectedTargetId, setSelectedTargetId] = useState<string>('');
const [selectedChannels, setSelectedChannels] = useState<string[]>(['app', 'push']);
// Целевой пользователь: отличается от текущего только при админском просмотре чужой карточки
const targetUserId = userId && currentUser && userId !== currentUser.id ? userId : undefined;
const { data: eventTypesData } = useQuery<{ success: boolean; eventTypes: NotificationEventType[] }>({
queryKey: ['/api/subscriptions/event-types'],
staleTime: 5 * 60 * 1000,
});
const { data: subscriptionsData } = useQuery<{ success: boolean; subscriptions: NotificationSubscription[] }>({
queryKey: targetUserId ? ['/api/subscriptions', 'user', targetUserId] : ['/api/subscriptions'],
queryFn: targetUserId
? async () => {
const res = await apiRequest('GET', `/api/subscriptions?userId=${targetUserId}`);
return res.json();
}
: undefined,
});
const { data: formsData } = useQuery<{ success: boolean; forms: Form[] }>({
queryKey: ['/api/forms'],
staleTime: 5 * 60 * 1000,
});
const createSubscriptionMutation = useMutation({
mutationFn: async (data: { eventTypeCode: string; targetType?: string; targetId?: number; channels: string[] }) => {
return apiRequest('POST', '/api/subscriptions', targetUserId ? { ...data, userId: targetUserId } : data);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['/api/subscriptions'] });
toast({ title: 'Подписка создана' });
setSelectedEventType('');
setSelectedTargetType('all');
setSelectedTargetId('');
},
onError: () => {
toast({ title: 'Ошибка создания подписки', variant: 'destructive' });
},
});
const updateSubscriptionMutation = useMutation({
mutationFn: async ({ id, data }: { id: number; data: { channels?: string[]; isActive?: boolean } }) => {
return apiRequest('PATCH', `/api/subscriptions/${id}`, data);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['/api/subscriptions'] });
},
onError: () => {
toast({ title: 'Ошибка обновления подписки', variant: 'destructive' });
},
});
const deleteSubscriptionMutation = useMutation({
mutationFn: async (id: number) => {
return apiRequest('DELETE', `/api/subscriptions/${id}`);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['/api/subscriptions'] });
toast({ title: 'Подписка удалена' });
},
onError: () => {
toast({ title: 'Ошибка удаления подписки', variant: 'destructive' });
},
});
const eventTypes = eventTypesData?.eventTypes || [];
const subscriptions = subscriptionsData?.subscriptions || [];
const forms = formsData?.forms || [];
const getEventTypeName = (code: string) => {
const eventType = eventTypes.find(et => et.code === code);
return eventType?.name || code;
};
const getCategoryColor = (category: string) => {
switch (category) {
case 'task': return 'bg-info text-info-foreground';
case 'chat': return 'bg-success text-success-foreground';
case 'form': return 'bg-warning text-warning-foreground';
case 'system': return 'bg-muted text-muted-foreground';
default: return 'bg-muted text-muted-foreground';
}
};
const getCategoryLabel = (category: string) => {
switch (category) {
case 'task': return 'Задачи';
case 'chat': return 'Чат';
case 'form': return 'Формы';
case 'system': return 'Система';
default: return category;
}
};
const getTargetLabel = (sub: NotificationSubscription) => {
if (!sub.targetType || sub.targetType === 'all') {
return 'Все';
}
if (sub.targetType === 'form' && sub.targetId) {
const form = forms.find(f => f.id === sub.targetId);
return form ? `Форма: ${form.name}` : `Форма #${sub.targetId}`;
}
if (sub.targetType === 'task' && sub.targetId) {
return `Задача #${sub.targetId}`;
}
return sub.targetType;
};
const handleAddSubscription = () => {
if (!selectedEventType) {
toast({ title: 'Выберите тип события', variant: 'destructive' });
return;
}
const data: { eventTypeCode: string; targetType?: string; targetId?: number; channels: string[] } = {
eventTypeCode: selectedEventType,
channels: selectedChannels,
};
if (selectedTargetType && selectedTargetType !== 'all') {
data.targetType = selectedTargetType;
if (selectedTargetId) {
data.targetId = parseInt(selectedTargetId);
}
}
createSubscriptionMutation.mutate(data);
};
const handleToggleSubscription = (sub: NotificationSubscription) => {
updateSubscriptionMutation.mutate({
id: sub.id,
data: { isActive: !sub.isActive },
});
};
const handleToggleChannel = (sub: NotificationSubscription, channel: string) => {
const channels = sub.channels.includes(channel)
? sub.channels.filter(c => c !== channel)
: [...sub.channels, channel];
if (channels.length === 0) {
channels.push('app');
}
updateSubscriptionMutation.mutate({
id: sub.id,
data: { channels },
});
};
const groupedEventTypes = eventTypes.reduce((acc, et) => {
if (!acc[et.category]) {
acc[et.category] = [];
}
acc[et.category].push(et);
return acc;
}, {} as Record<string, NotificationEventType[]>);
return (
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Plus className="w-5 h-5" />
Добавить подписку
</CardTitle>
<CardDescription>
Выберите события, о которых хотите получать уведомления
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div className="space-y-2">
<Label>Тип события</Label>
<Select value={selectedEventType} onValueChange={setSelectedEventType}>
<SelectTrigger data-testid="select-event-type">
<SelectValue placeholder="Выберите событие" />
</SelectTrigger>
<SelectContent>
{Object.entries(groupedEventTypes).map(([category, types]) => (
<div key={category}>
<div className="px-2 py-1.5 text-xs font-semibold text-muted-foreground">
{getCategoryLabel(category)}
</div>
{types.map(et => (
<SelectItem key={et.code} value={et.code}>
{et.name}
</SelectItem>
))}
</div>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Область</Label>
<Select value={selectedTargetType} onValueChange={setSelectedTargetType}>
<SelectTrigger data-testid="select-target-type">
<SelectValue placeholder="Выберите область" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Все события</SelectItem>
<SelectItem value="form">Конкретная форма</SelectItem>
</SelectContent>
</Select>
</div>
{selectedTargetType === 'form' && (
<div className="space-y-2">
<Label>Форма</Label>
<Select value={selectedTargetId} onValueChange={setSelectedTargetId}>
<SelectTrigger data-testid="select-target-form">
<SelectValue placeholder="Выберите форму" />
</SelectTrigger>
<SelectContent>
{forms.map(form => (
<SelectItem key={form.id} value={form.id.toString()}>
{form.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
<div className="space-y-2">
<Label>Каналы</Label>
<div className="flex gap-2">
<Button
type="button"
size="sm"
variant={selectedChannels.includes('app') ? 'default' : 'outline'}
title="В приложении"
onClick={() => {
if (selectedChannels.includes('app')) {
if (selectedChannels.length > 1) {
setSelectedChannels(selectedChannels.filter(c => c !== 'app'));
}
} else {
setSelectedChannels([...selectedChannels, 'app']);
}
}}
data-testid="button-channel-app"
>
<Bell className="w-4 h-4" />
</Button>
<Button
type="button"
size="sm"
variant={selectedChannels.includes('push') ? 'default' : 'outline'}
title="Push-уведомления (браузер/PWA)"
onClick={() => {
if (selectedChannels.includes('push')) {
setSelectedChannels(selectedChannels.filter(c => c !== 'push'));
} else {
setSelectedChannels([...selectedChannels, 'push']);
}
}}
data-testid="button-channel-push"
>
<Smartphone className="w-4 h-4" />
</Button>
<Button
type="button"
size="sm"
variant={selectedChannels.includes('email') ? 'default' : 'outline'}
title="Email"
onClick={() => {
if (selectedChannels.includes('email')) {
setSelectedChannels(selectedChannels.filter(c => c !== 'email'));
} else {
setSelectedChannels([...selectedChannels, 'email']);
}
}}
data-testid="button-channel-email"
>
<Mail className="w-4 h-4" />
</Button>
</div>
</div>
</div>
<Button
onClick={handleAddSubscription}
disabled={!selectedEventType || createSubscriptionMutation.isPending}
data-testid="button-add-subscription"
>
{createSubscriptionMutation.isPending ? (
<Loader2 className="w-4 h-4 animate-spin mr-2" />
) : (
<Plus className="w-4 h-4 mr-2" />
)}
Добавить подписку
</Button>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Мои подписки</CardTitle>
<CardDescription>
Управляйте своими уведомлениями
</CardDescription>
</CardHeader>
<CardContent>
{subscriptions.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
У вас пока нет подписок на события
</div>
) : (
<div className="space-y-3">
{subscriptions.map(sub => (
<div
key={sub.id}
className={`flex items-center justify-between p-4 rounded-lg border ${
sub.isActive ? 'bg-background' : 'bg-muted/50 opacity-60'
}`}
data-testid={`subscription-row-${sub.id}`}
>
<div className="flex items-center gap-4">
<Switch
checked={sub.isActive}
onCheckedChange={() => handleToggleSubscription(sub)}
data-testid={`switch-subscription-${sub.id}`}
/>
<div>
<div className="font-medium">{getEventTypeName(sub.eventTypeCode)}</div>
<div className="text-sm text-muted-foreground">
{getTargetLabel(sub)}
</div>
</div>
</div>
<div className="flex items-center gap-4">
<div className="flex gap-1">
<Button
type="button"
size="sm"
variant={sub.channels.includes('app') ? 'default' : 'outline'}
onClick={() => handleToggleChannel(sub, 'app')}
disabled={updateSubscriptionMutation.isPending}
title="В приложении"
data-testid={`button-toggle-app-${sub.id}`}
>
<Bell className="w-4 h-4" />
</Button>
<Button
type="button"
size="sm"
variant={sub.channels.includes('push') ? 'default' : 'outline'}
onClick={() => handleToggleChannel(sub, 'push')}
disabled={updateSubscriptionMutation.isPending}
title="Push-уведомления (браузер/PWA)"
data-testid={`button-toggle-push-${sub.id}`}
>
<Smartphone className="w-4 h-4" />
</Button>
<Button
type="button"
size="sm"
variant={sub.channels.includes('email') ? 'default' : 'outline'}
onClick={() => handleToggleChannel(sub, 'email')}
disabled={updateSubscriptionMutation.isPending}
title="Email"
data-testid={`button-toggle-email-${sub.id}`}
>
<Mail className="w-4 h-4" />
</Button>
</div>
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => deleteSubscriptionMutation.mutate(sub.id)}
disabled={deleteSubscriptionMutation.isPending}
data-testid={`button-delete-subscription-${sub.id}`}
>
<Trash2 className="w-4 h-4 text-destructive" />
</Button>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Доступные типы событий</CardTitle>
<CardDescription>
События, на которые можно подписаться
</CardDescription>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{Object.entries(groupedEventTypes).map(([category, types]) => (
<div key={category} className="space-y-2">
<h4 className="font-medium flex items-center gap-2">
<Badge className={getCategoryColor(category)}>
{getCategoryLabel(category)}
</Badge>
</h4>
<ul className="space-y-1">
{types.map(et => (
<li key={et.code} className="text-sm text-muted-foreground pl-2">
• {et.name}
{et.description && (
<span className="text-xs ml-1">- {et.description}</span>
)}
</li>
))}
</ul>
</div>
))}
</div>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,182 @@
import { useQuery, useMutation } from '@tanstack/react-query';
import { queryClient, apiRequest } from '@/lib/queryClient';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Switch } from '@/components/ui/switch';
import { Checkbox } from '@/components/ui/checkbox';
import { Label } from '@/components/ui/label';
import { useToast } from '@/hooks/use-toast';
import { Loader2, WifiOff } from 'lucide-react';
type OfflineForm = {
id: number;
name: string;
offlineCache?: {
enabled: boolean;
maxTasks?: number;
strategy?: 'recent' | 'assigned' | 'all';
includeFields?: string[];
} | null;
};
type OfflineSubscription = {
id: number;
userId: number;
formId: number;
isActive: boolean;
includeRelatedTasks: boolean;
includeFiles: boolean;
};
/**
* Вкладка «Offline» единого экрана профиля.
* isSelf=false — админский просмотр чужой карточки: конфиг запрашивается с ?userId=
* (бэкенд проверяет canManageUser), локальный performInitialSync() не вызывается.
*/
export function ProfileOfflineTab({ userId, isSelf = true }: { userId: number; isSelf?: boolean }) {
const { toast } = useToast();
const { data: syncConfigData, isLoading: isLoadingSyncConfig } = useQuery<{
success: boolean;
forms: OfflineForm[];
subscriptions: OfflineSubscription[];
}>({
queryKey: isSelf ? ['/api/sync/config'] : ['/api/sync/config', 'user', userId],
queryFn: isSelf
? undefined
: async () => {
const res = await apiRequest('GET', `/api/sync/config?userId=${userId}`);
return res.json();
},
staleTime: 0,
});
const saveOfflineSubscriptionMutation = useMutation({
mutationFn: async (data: { formId: number; isActive: boolean; includeRelatedTasks: boolean; includeFiles: boolean }) => {
const res = await apiRequest('POST', `/api/users/${userId}/offline-subscriptions`, data);
return res.json() as Promise<{ success: boolean; subscription: OfflineSubscription }>;
},
onSuccess: async () => {
queryClient.invalidateQueries({ queryKey: ['/api/sync/config'] });
toast({ title: 'Подписка сохранена' });
// Локальная синхронизация устройства имеет смысл только для своих подписок
if (!isSelf) return;
try {
const { performInitialSync } = await import('@/lib/syncEngine');
await performInitialSync();
} catch (e) {
console.error('Initial sync failed after subscription change:', e);
}
},
onError: () => {
toast({ title: 'Ошибка сохранения подписки', variant: 'destructive' });
},
});
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<WifiOff className="w-5 h-5" />
Offline-кэш форм
</CardTitle>
<CardDescription>
Выберите формы, задачи которых будут сохраняться на устройстве для работы без интернета.
</CardDescription>
</CardHeader>
<CardContent>
{isLoadingSyncConfig ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
</div>
) : (syncConfigData?.forms || []).filter(f => f.offlineCache?.enabled).length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
Нет форм, разрешённых для offline-кэширования. Администратор может включить их в редакторе формы.
</div>
) : (
<div className="space-y-3">
{(syncConfigData?.forms || [])
.filter(f => f.offlineCache?.enabled)
.map(form => {
const sub = (syncConfigData?.subscriptions || []).find(s => s.formId === form.id);
const isActive = sub?.isActive ?? false;
return (
<div
key={form.id}
className={`flex flex-col md:flex-row md:items-center justify-between p-4 rounded-lg border gap-4 ${
isActive ? 'bg-background' : 'bg-muted/50 opacity-70'
}`}
>
<div className="flex items-center gap-4">
<Switch
checked={isActive}
onCheckedChange={(checked) =>
saveOfflineSubscriptionMutation.mutate({
formId: form.id,
isActive: checked,
includeRelatedTasks: sub?.includeRelatedTasks ?? true,
includeFiles: sub?.includeFiles ?? false,
})
}
disabled={saveOfflineSubscriptionMutation.isPending}
/>
<div>
<div className="font-medium">{form.name}</div>
<div className="text-sm text-muted-foreground">
Макс. {form.offlineCache?.maxTasks ?? 500} задач ·{' '}
{form.offlineCache?.strategy === 'assigned'
? 'назначенные мне'
: form.offlineCache?.strategy === 'all'
? 'все задачи'
: 'последние по дате'}
</div>
</div>
</div>
{isActive && (
<div className="flex items-center gap-4 pl-12 md:pl-0">
<div className="flex items-center gap-2">
<Checkbox
checked={sub?.includeRelatedTasks ?? true}
onCheckedChange={(checked: boolean | 'indeterminate') =>
saveOfflineSubscriptionMutation.mutate({
formId: form.id,
isActive: true,
includeRelatedTasks: checked === true,
includeFiles: sub?.includeFiles ?? false,
})
}
disabled={saveOfflineSubscriptionMutation.isPending}
id={`offline-sub-related-${form.id}`}
/>
<Label htmlFor={`offline-sub-related-${form.id}`} className="text-sm cursor-pointer">
Связанные задачи
</Label>
</div>
<div className="flex items-center gap-2">
<Checkbox
checked={sub?.includeFiles ?? false}
onCheckedChange={(checked: boolean | 'indeterminate') =>
saveOfflineSubscriptionMutation.mutate({
formId: form.id,
isActive: true,
includeRelatedTasks: sub?.includeRelatedTasks ?? true,
includeFiles: checked === true,
})
}
disabled={saveOfflineSubscriptionMutation.isPending}
id={`offline-sub-files-${form.id}`}
/>
<Label htmlFor={`offline-sub-files-${form.id}`} className="text-sm cursor-pointer">
Файлы
</Label>
</div>
</div>
)}
</div>
);
})}
</div>
)}
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,163 @@
import { useState } from 'react';
import { useMutation } from '@tanstack/react-query';
import { useLocation } from 'wouter';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Label } from '@/components/ui/label';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { useToast } from '@/hooks/use-toast';
import { useAuth } from '@/hooks/useAuth';
import { authService } from '@/services/auth.service';
import { Loader2, Lock, LogOut } from 'lucide-react';
/**
* Вкладка «Безопасность» единого экрана профиля: смена своего пароля и выход из аккаунта.
* Смена пароля self-only, поэтому вкладка рендерится только для своего профиля.
*/
export function ProfileSecurityTab() {
const { toast } = useToast();
const { logout } = useAuth();
const [, setLocation] = useLocation();
const [currentPassword, setCurrentPassword] = useState('');
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
// Текст ошибки валидации нового пароля — показывается рядом с полем
const [passwordError, setPasswordError] = useState<string | null>(null);
const changePasswordMutation = useMutation({
mutationFn: async (data: { currentPassword: string; newPassword: string }) => {
return authService.changePassword(data.currentPassword, data.newPassword);
},
onSuccess: (data) => {
if (data.success) {
toast({ title: data.message || 'Пароль успешно изменен' });
setCurrentPassword('');
setNewPassword('');
setConfirmPassword('');
setPasswordError(null);
} else {
toast({ title: data.error || 'Ошибка при изменении пароля', variant: 'destructive' });
}
},
onError: () => {
toast({ title: 'Ошибка при изменении пароля', variant: 'destructive' });
},
});
return (
<div className="space-y-4">
<Card>
<CardHeader>
<CardTitle>Смена пароля</CardTitle>
<CardDescription>
Введите текущий пароль и новый пароль для вашего аккаунта
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="current-password">Текущий пароль</Label>
<Input
id="current-password"
type="password"
autoComplete="current-password"
value={currentPassword}
onChange={e => setCurrentPassword(e.target.value)}
placeholder="Введите текущий пароль"
/>
</div>
<div className="space-y-2">
<Label htmlFor="new-password">Новый пароль</Label>
<Input
id="new-password"
type="password"
autoComplete="new-password"
value={newPassword}
onChange={e => {
setNewPassword(e.target.value);
setPasswordError(null);
}}
placeholder="Введите новый пароль"
aria-invalid={!!passwordError}
aria-describedby={passwordError ? 'new-password-error' : undefined}
/>
<p className="text-xs text-muted-foreground">
Минимум 8 символов, заглавная и строчная буква, цифра и специальный символ
</p>
</div>
<div className="space-y-2">
<Label htmlFor="confirm-password">Подтвердите новый пароль</Label>
<Input
id="confirm-password"
type="password"
autoComplete="new-password"
value={confirmPassword}
onChange={e => {
setConfirmPassword(e.target.value);
setPasswordError(null);
}}
placeholder="Повторите новый пароль"
/>
{passwordError && (
<p id="new-password-error" className="text-xs text-destructive">
{passwordError}
</p>
)}
</div>
<Button
onClick={() => {
if (!currentPassword || !newPassword || !confirmPassword) {
toast({ title: 'Заполните все поля', variant: 'destructive' });
return;
}
if (newPassword !== confirmPassword) {
setPasswordError('Новые пароли не совпадают');
toast({ title: 'Новые пароли не совпадают', variant: 'destructive' });
return;
}
// Показываем конкретное невыполненное правило, а не общий текст
const failedRules: string[] = [];
if (newPassword.length < 8) failedRules.push('минимум 8 символов');
if (!/[A-Z]/.test(newPassword)) failedRules.push('заглавную букву');
if (!/[a-z]/.test(newPassword)) failedRules.push('строчную букву');
if (!/\d/.test(newPassword)) failedRules.push('цифру');
if (!/[!@#$%^&*(),.?":{}|<>]/.test(newPassword)) failedRules.push('специальный символ');
if (failedRules.length > 0) {
const message = `Пароль должен содержать: ${failedRules.join(', ')}`;
setPasswordError(message);
toast({ title: message, variant: 'destructive' });
return;
}
changePasswordMutation.mutate({ currentPassword, newPassword });
}}
disabled={changePasswordMutation.isPending}
>
{changePasswordMutation.isPending ? (
<Loader2 className="w-4 h-4 animate-spin mr-2" />
) : (
<Lock className="w-4 h-4 mr-2" />
)}
Изменить пароль
</Button>
</CardContent>
</Card>
<Card>
<CardContent className="flex items-center justify-between py-4">
<span className="font-medium">Сессия</span>
<Button
variant="destructive"
size="sm"
onClick={async () => {
await logout();
setLocation('/login');
}}
className="flex items-center gap-2"
>
<LogOut className="w-4 h-4" />
Выйти
</Button>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,34 @@
import { useEffect } from 'react';
import { useLocation } from 'wouter';
import { Loader2 } from 'lucide-react';
import Layout from '@/components/Layout';
import { useAuth } from '@/hooks/useAuth';
import UserProfilePage from '@/pages/UserProfilePage';
/**
* Обёртка маршрута /profile: рендерит единый экран профиля (UserProfilePage)
* для текущего пользователя. URL остаётся /profile.
* Не залогинен — редирект на /login (как было в ProfileSettings).
*/
export default function MyProfilePage() {
const { user, isLoading } = useAuth();
const [location, setLocation] = useLocation();
useEffect(() => {
if (!isLoading && !user) {
setLocation(`/login?returnTo=${encodeURIComponent(location)}`);
}
}, [user, isLoading, location, setLocation]);
if (isLoading || !user) {
return (
<Layout>
<div className="flex items-center justify-center h-64">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
</Layout>
);
}
return <UserProfilePage userId={user.id} />;
}

View File

@@ -1,956 +0,0 @@
import { useState, useEffect } from 'react';
import Layout from '@/components/Layout';
import { useQuery, useMutation } from '@tanstack/react-query';
import { queryClient, apiRequest } from '@/lib/queryClient';
import { useAuth } from '@/hooks/useAuth';
import { authService } from '@/services/auth.service';
import { isAdmin } from '@/lib/permissions';
import { useTheme } from '@/hooks/use-theme';
import { useSkin } from '@/themes/ThemeProvider';
import { useLocation } from 'wouter';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Switch } from '@/components/ui/switch';
import { Checkbox } from '@/components/ui/checkbox';
import { Label } from '@/components/ui/label';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Input } from '@/components/ui/input';
import { useToast } from '@/hooks/use-toast';
import { Bell, Mail, User, Plus, Trash2, Loader2, ArrowLeft, Smartphone, LogOut, Moon, Sun, Lock, Type, WifiOff, Palette } from 'lucide-react';
import { usePwaFontSize } from '@/hooks/usePwaFontSize';
import { Link } from 'wouter';
type NotificationEventType = {
id: number;
code: string;
name: string;
description: string | null;
category: string;
isActive: boolean;
};
type NotificationSubscription = {
id: number;
userId: number;
organizationId: number;
eventTypeCode: string;
targetType: string | null;
targetId: number | null;
channels: string[];
isActive: boolean;
};
type Form = {
id: number;
name: string;
};
type OfflineForm = Form & {
offlineCache?: {
enabled: boolean;
maxTasks?: number;
strategy?: 'recent' | 'assigned' | 'all';
includeFields?: string[];
} | null;
};
type OfflineSubscription = {
id: number;
userId: number;
formId: number;
isActive: boolean;
includeRelatedTasks: boolean;
includeFiles: boolean;
};
const ProfileSettings = () => {
const { toast } = useToast();
const { user, isLoading: isAuthLoading, logout } = useAuth();
const { theme, toggleTheme } = useTheme();
const { skinId, setSkinId, availableSkins } = useSkin();
const { isPwa, fontSize, increase: increaseFontSize, decrease: decreaseFontSize } = usePwaFontSize(user?.id);
const [location, setLocation] = useLocation();
const [selectedEventType, setSelectedEventType] = useState<string>('');
const [selectedTargetType, setSelectedTargetType] = useState<string>('all');
const [selectedTargetId, setSelectedTargetId] = useState<string>('');
const [selectedChannels, setSelectedChannels] = useState<string[]>(['app', 'push']);
const [currentPassword, setCurrentPassword] = useState('');
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
// Текст ошибки валидации нового пароля — показывается рядом с полем
const [passwordError, setPasswordError] = useState<string | null>(null);
useEffect(() => {
if (!isAuthLoading && !user) {
setLocation(`/login?returnTo=${encodeURIComponent(location)}`);
}
}, [user, isAuthLoading, location, setLocation]);
const { data: eventTypesData, isLoading: isLoadingEventTypes } = useQuery<{ success: boolean; eventTypes: NotificationEventType[] }>({
queryKey: ['/api/subscriptions/event-types'],
staleTime: 5 * 60 * 1000,
enabled: !!user,
});
const { data: subscriptionsData, isLoading: isLoadingSubscriptions } = useQuery<{ success: boolean; subscriptions: NotificationSubscription[] }>({
queryKey: ['/api/subscriptions'],
enabled: !!user,
});
const { data: formsData } = useQuery<{ success: boolean; forms: Form[] }>({
queryKey: ['/api/forms'],
staleTime: 5 * 60 * 1000,
enabled: !!user,
});
const createSubscriptionMutation = useMutation({
mutationFn: async (data: { eventTypeCode: string; targetType?: string; targetId?: number; channels: string[] }) => {
return apiRequest('POST', '/api/subscriptions', data);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['/api/subscriptions'] });
toast({ title: 'Подписка создана' });
setSelectedEventType('');
setSelectedTargetType('all');
setSelectedTargetId('');
},
onError: () => {
toast({ title: 'Ошибка создания подписки', variant: 'destructive' });
},
});
const updateSubscriptionMutation = useMutation({
mutationFn: async ({ id, data }: { id: number; data: { channels?: string[]; isActive?: boolean } }) => {
return apiRequest('PATCH', `/api/subscriptions/${id}`, data);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['/api/subscriptions'] });
},
onError: () => {
toast({ title: 'Ошибка обновления подписки', variant: 'destructive' });
},
});
const deleteSubscriptionMutation = useMutation({
mutationFn: async (id: number) => {
return apiRequest('DELETE', `/api/subscriptions/${id}`);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['/api/subscriptions'] });
toast({ title: 'Подписка удалена' });
},
onError: () => {
toast({ title: 'Ошибка удаления подписки', variant: 'destructive' });
},
});
const changePasswordMutation = useMutation({
mutationFn: async (data: { currentPassword: string; newPassword: string }) => {
return authService.changePassword(data.currentPassword, data.newPassword);
},
onSuccess: (data) => {
if (data.success) {
toast({ title: data.message || 'Пароль успешно изменен' });
setCurrentPassword('');
setNewPassword('');
setConfirmPassword('');
setPasswordError(null);
} else {
toast({ title: data.error || 'Ошибка при изменении пароля', variant: 'destructive' });
}
},
onError: () => {
toast({ title: 'Ошибка при изменении пароля', variant: 'destructive' });
},
});
const { data: syncConfigData, isLoading: isLoadingSyncConfig } = useQuery<{
success: boolean;
forms: OfflineForm[];
subscriptions: OfflineSubscription[];
}>({
queryKey: ['/api/sync/config'],
enabled: !!user,
staleTime: 0,
});
const saveOfflineSubscriptionMutation = useMutation({
mutationFn: async (data: { formId: number; isActive: boolean; includeRelatedTasks: boolean; includeFiles: boolean }) => {
const res = await apiRequest('POST', `/api/users/${user!.id}/offline-subscriptions`, data);
return res.json() as Promise<{ success: boolean; subscription: OfflineSubscription }>;
},
onSuccess: async () => {
queryClient.invalidateQueries({ queryKey: ['/api/sync/config'] });
toast({ title: 'Подписка сохранена' });
try {
const { performInitialSync } = await import('@/lib/syncEngine');
await performInitialSync();
} catch (e) {
console.error('Initial sync failed after subscription change:', e);
}
},
onError: () => {
toast({ title: 'Ошибка сохранения подписки', variant: 'destructive' });
},
});
const deleteOfflineSubscriptionMutation = useMutation({
mutationFn: async (formId: number) => {
const res = await apiRequest('DELETE', `/api/users/${user!.id}/offline-subscriptions/${formId}`);
return res.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['/api/sync/config'] });
toast({ title: 'Подписка удалена' });
},
onError: () => {
toast({ title: 'Ошибка удаления подписки', variant: 'destructive' });
},
});
const eventTypes = eventTypesData?.eventTypes || [];
const subscriptions = subscriptionsData?.subscriptions || [];
const forms = formsData?.forms || [];
const getEventTypeName = (code: string) => {
const eventType = eventTypes.find(et => et.code === code);
return eventType?.name || code;
};
const getCategoryColor = (category: string) => {
switch (category) {
case 'task': return 'bg-info text-info-foreground';
case 'chat': return 'bg-success text-success-foreground';
case 'form': return 'bg-warning text-warning-foreground';
case 'system': return 'bg-muted text-muted-foreground';
default: return 'bg-muted text-muted-foreground';
}
};
const getCategoryLabel = (category: string) => {
switch (category) {
case 'task': return 'Задачи';
case 'chat': return 'Чат';
case 'form': return 'Формы';
case 'system': return 'Система';
default: return category;
}
};
const getTargetLabel = (sub: NotificationSubscription) => {
if (!sub.targetType || sub.targetType === 'all') {
return 'Все';
}
if (sub.targetType === 'form' && sub.targetId) {
const form = forms.find(f => f.id === sub.targetId);
return form ? `Форма: ${form.name}` : `Форма #${sub.targetId}`;
}
if (sub.targetType === 'task' && sub.targetId) {
return `Задача #${sub.targetId}`;
}
return sub.targetType;
};
const handleAddSubscription = () => {
if (!selectedEventType) {
toast({ title: 'Выберите тип события', variant: 'destructive' });
return;
}
const data: { eventTypeCode: string; targetType?: string; targetId?: number; channels: string[] } = {
eventTypeCode: selectedEventType,
channels: selectedChannels,
};
if (selectedTargetType && selectedTargetType !== 'all') {
data.targetType = selectedTargetType;
if (selectedTargetId) {
data.targetId = parseInt(selectedTargetId);
}
}
createSubscriptionMutation.mutate(data);
};
const handleToggleSubscription = (sub: NotificationSubscription) => {
updateSubscriptionMutation.mutate({
id: sub.id,
data: { isActive: !sub.isActive },
});
};
const handleToggleChannel = (sub: NotificationSubscription, channel: string) => {
const channels = sub.channels.includes(channel)
? sub.channels.filter(c => c !== channel)
: [...sub.channels, channel];
if (channels.length === 0) {
channels.push('app');
}
updateSubscriptionMutation.mutate({
id: sub.id,
data: { channels },
});
};
const groupedEventTypes = eventTypes.reduce((acc, et) => {
if (!acc[et.category]) {
acc[et.category] = [];
}
acc[et.category].push(et);
return acc;
}, {} as Record<string, NotificationEventType[]>);
if (isAuthLoading || !user || isLoadingEventTypes || isLoadingSubscriptions) {
return (
<Layout>
<main className="flex-1 overflow-auto p-6">
<div className="max-w-4xl mx-auto flex items-center justify-center py-20">
<Loader2 className="w-8 h-8 animate-spin text-muted-foreground" />
</div>
</main>
</Layout>
);
}
return (
<Layout>
<main className="flex-1 overflow-auto p-4">
<div className="max-w-4xl mx-auto">
<div className="flex items-center gap-2 mb-3 pb-2 border-b border-border">
<Link href="/home">
<button className="p-1 rounded text-muted-foreground hover:text-foreground hover:bg-muted transition-colors" data-testid="button-back">
<ArrowLeft className="w-3.5 h-3.5" />
</button>
</Link>
<User className="w-4 h-4 text-muted-foreground" />
<div>
<h1 className="text-sm font-medium" data-testid="text-profile-title">Настройки профиля</h1>
<p className="text-[10px] text-muted-foreground">Управление аккаунтом и уведомлениями</p>
</div>
</div>
<Tabs defaultValue="profile" className="space-y-6">
<TabsList>
<TabsTrigger value="profile" className="flex items-center gap-2">
<User className="w-4 h-4" />
Профиль
</TabsTrigger>
<TabsTrigger value="security" className="flex items-center gap-2">
<Lock className="w-4 h-4" />
Безопасность
</TabsTrigger>
<TabsTrigger value="notifications" className="flex items-center gap-2">
<Bell className="w-4 h-4" />
Уведомления
</TabsTrigger>
<TabsTrigger value="offline" className="flex items-center gap-2">
<WifiOff className="w-4 h-4" />
Offline
</TabsTrigger>
</TabsList>
<TabsContent value="profile" className="space-y-6">
<Card>
<CardHeader>
<CardTitle>Личные данные</CardTitle>
<CardDescription>
Информация о вашем аккаунте
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="space-y-2">
<Label>Имя</Label>
<Input value={user.firstName || ''} disabled />
</div>
<div className="space-y-2">
<Label>Отчество</Label>
<Input value={user.middleName || ''} disabled />
</div>
<div className="space-y-2">
<Label>Фамилия</Label>
<Input value={user.lastName || ''} disabled />
</div>
<div className="space-y-2">
<Label>Email</Label>
<Input value={user.email || ''} disabled />
</div>
<div className="space-y-2">
<Label>Роль</Label>
<Input value={isAdmin(user) ? 'Администратор' : 'Пользователь'} disabled />
</div>
</div>
<p className="text-sm text-muted-foreground">
Для изменения личных данных обратитесь к администратору
</p>
</CardContent>
</Card>
<Card>
<CardContent className="flex items-center justify-between py-4">
<span className="font-medium">Сессия</span>
<Button
variant="destructive"
size="sm"
onClick={async () => {
await logout();
setLocation('/login');
}}
className="flex items-center gap-2"
>
<LogOut className="w-4 h-4" />
Выйти
</Button>
</CardContent>
</Card>
<Card>
<CardContent className="flex items-center justify-between py-4">
<div className="flex items-center gap-2">
{theme === 'dark' ? (
<Moon className="w-4 h-4 text-muted-foreground" />
) : (
<Sun className="w-4 h-4 text-muted-foreground" />
)}
<span className="font-medium">Тема</span>
</div>
<Switch
checked={theme === 'dark'}
onCheckedChange={toggleTheme}
/>
</CardContent>
</Card>
<Card>
<CardContent className="flex items-center justify-between py-4">
<div className="flex items-center gap-2">
<Palette className="w-4 h-4 text-muted-foreground" />
<span className="font-medium">Скин интерфейса</span>
</div>
<Select value={skinId} onValueChange={setSkinId}>
<SelectTrigger className="w-[180px]" data-testid="select-skin">
<SelectValue placeholder="Выберите скин" />
</SelectTrigger>
<SelectContent>
{availableSkins.map(skin => (
<SelectItem key={skin.id} value={skin.id}>
{skin.name}
</SelectItem>
))}
</SelectContent>
</Select>
</CardContent>
</Card>
{isPwa && (
<Card>
<CardContent className="flex items-center justify-between py-4">
<div className="flex items-center gap-2">
<Type className="w-4 h-4 text-muted-foreground" />
<span className="font-medium">Шрифт</span>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="icon"
className="h-7 w-7 text-xs"
onClick={decreaseFontSize}
disabled={fontSize <= 12}
>
−
</Button>
<span className="text-sm w-8 text-center tabular-nums">{fontSize}</span>
<Button
variant="outline"
size="icon"
className="h-7 w-7 text-xs"
onClick={increaseFontSize}
disabled={fontSize >= 24}
>
+
</Button>
</div>
</CardContent>
</Card>
)}
</TabsContent>
<TabsContent value="notifications" className="space-y-6">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Plus className="w-5 h-5" />
Добавить подписку
</CardTitle>
<CardDescription>
Выберите события, о которых хотите получать уведомления
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div className="space-y-2">
<Label>Тип события</Label>
<Select value={selectedEventType} onValueChange={setSelectedEventType}>
<SelectTrigger data-testid="select-event-type">
<SelectValue placeholder="Выберите событие" />
</SelectTrigger>
<SelectContent>
{Object.entries(groupedEventTypes).map(([category, types]) => (
<div key={category}>
<div className="px-2 py-1.5 text-xs font-semibold text-muted-foreground">
{getCategoryLabel(category)}
</div>
{types.map(et => (
<SelectItem key={et.code} value={et.code}>
{et.name}
</SelectItem>
))}
</div>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Область</Label>
<Select value={selectedTargetType} onValueChange={setSelectedTargetType}>
<SelectTrigger data-testid="select-target-type">
<SelectValue placeholder="Выберите область" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Все события</SelectItem>
<SelectItem value="form">Конкретная форма</SelectItem>
</SelectContent>
</Select>
</div>
{selectedTargetType === 'form' && (
<div className="space-y-2">
<Label>Форма</Label>
<Select value={selectedTargetId} onValueChange={setSelectedTargetId}>
<SelectTrigger data-testid="select-target-form">
<SelectValue placeholder="Выберите форму" />
</SelectTrigger>
<SelectContent>
{forms.map(form => (
<SelectItem key={form.id} value={form.id.toString()}>
{form.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
<div className="space-y-2">
<Label>Каналы</Label>
<div className="flex gap-2">
<Button
type="button"
size="sm"
variant={selectedChannels.includes('app') ? 'default' : 'outline'}
title="В приложении"
onClick={() => {
if (selectedChannels.includes('app')) {
if (selectedChannels.length > 1) {
setSelectedChannels(selectedChannels.filter(c => c !== 'app'));
}
} else {
setSelectedChannels([...selectedChannels, 'app']);
}
}}
data-testid="button-channel-app"
>
<Bell className="w-4 h-4" />
</Button>
<Button
type="button"
size="sm"
variant={selectedChannels.includes('push') ? 'default' : 'outline'}
title="Push-уведомления (браузер/PWA)"
onClick={() => {
if (selectedChannels.includes('push')) {
setSelectedChannels(selectedChannels.filter(c => c !== 'push'));
} else {
setSelectedChannels([...selectedChannels, 'push']);
}
}}
data-testid="button-channel-push"
>
<Smartphone className="w-4 h-4" />
</Button>
<Button
type="button"
size="sm"
variant={selectedChannels.includes('email') ? 'default' : 'outline'}
title="Email"
onClick={() => {
if (selectedChannels.includes('email')) {
setSelectedChannels(selectedChannels.filter(c => c !== 'email'));
} else {
setSelectedChannels([...selectedChannels, 'email']);
}
}}
data-testid="button-channel-email"
>
<Mail className="w-4 h-4" />
</Button>
</div>
</div>
</div>
<Button
onClick={handleAddSubscription}
disabled={!selectedEventType || createSubscriptionMutation.isPending}
data-testid="button-add-subscription"
>
{createSubscriptionMutation.isPending ? (
<Loader2 className="w-4 h-4 animate-spin mr-2" />
) : (
<Plus className="w-4 h-4 mr-2" />
)}
Добавить подписку
</Button>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Мои подписки</CardTitle>
<CardDescription>
Управляйте своими уведомлениями
</CardDescription>
</CardHeader>
<CardContent>
{subscriptions.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
У вас пока нет подписок на события
</div>
) : (
<div className="space-y-3">
{subscriptions.map(sub => (
<div
key={sub.id}
className={`flex items-center justify-between p-4 rounded-lg border ${
sub.isActive ? 'bg-background' : 'bg-muted/50 opacity-60'
}`}
data-testid={`subscription-row-${sub.id}`}
>
<div className="flex items-center gap-4">
<Switch
checked={sub.isActive}
onCheckedChange={() => handleToggleSubscription(sub)}
data-testid={`switch-subscription-${sub.id}`}
/>
<div>
<div className="font-medium">{getEventTypeName(sub.eventTypeCode)}</div>
<div className="text-sm text-muted-foreground">
{getTargetLabel(sub)}
</div>
</div>
</div>
<div className="flex items-center gap-4">
<div className="flex gap-1">
<Button
type="button"
size="sm"
variant={sub.channels.includes('app') ? 'default' : 'outline'}
onClick={() => handleToggleChannel(sub, 'app')}
disabled={updateSubscriptionMutation.isPending}
title="В приложении"
data-testid={`button-toggle-app-${sub.id}`}
>
<Bell className="w-4 h-4" />
</Button>
<Button
type="button"
size="sm"
variant={sub.channels.includes('push') ? 'default' : 'outline'}
onClick={() => handleToggleChannel(sub, 'push')}
disabled={updateSubscriptionMutation.isPending}
title="Push-уведомления (браузер/PWA)"
data-testid={`button-toggle-push-${sub.id}`}
>
<Smartphone className="w-4 h-4" />
</Button>
<Button
type="button"
size="sm"
variant={sub.channels.includes('email') ? 'default' : 'outline'}
onClick={() => handleToggleChannel(sub, 'email')}
disabled={updateSubscriptionMutation.isPending}
title="Email"
data-testid={`button-toggle-email-${sub.id}`}
>
<Mail className="w-4 h-4" />
</Button>
</div>
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => deleteSubscriptionMutation.mutate(sub.id)}
disabled={deleteSubscriptionMutation.isPending}
data-testid={`button-delete-subscription-${sub.id}`}
>
<Trash2 className="w-4 h-4 text-destructive" />
</Button>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Доступные типы событий</CardTitle>
<CardDescription>
События, на которые можно подписаться
</CardDescription>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{Object.entries(groupedEventTypes).map(([category, types]) => (
<div key={category} className="space-y-2">
<h4 className="font-medium flex items-center gap-2">
<Badge className={getCategoryColor(category)}>
{getCategoryLabel(category)}
</Badge>
</h4>
<ul className="space-y-1">
{types.map(et => (
<li key={et.code} className="text-sm text-muted-foreground pl-2">
• {et.name}
{et.description && (
<span className="text-xs ml-1">- {et.description}</span>
)}
</li>
))}
</ul>
</div>
))}
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="offline" className="space-y-6">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<WifiOff className="w-5 h-5" />
Offline-кэш форм
</CardTitle>
<CardDescription>
Выберите формы, задачи которых будут сохраняться на устройстве для работы без интернета.
</CardDescription>
</CardHeader>
<CardContent>
{isLoadingSyncConfig ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
</div>
) : (syncConfigData?.forms || []).filter(f => f.offlineCache?.enabled).length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
Нет форм, разрешённых для offline-кэширования. Администратор может включить их в редакторе формы.
</div>
) : (
<div className="space-y-3">
{(syncConfigData?.forms || [])
.filter(f => f.offlineCache?.enabled)
.map(form => {
const sub = (syncConfigData?.subscriptions || []).find(s => s.formId === form.id);
const isActive = sub?.isActive ?? false;
return (
<div
key={form.id}
className={`flex flex-col md:flex-row md:items-center justify-between p-4 rounded-lg border gap-4 ${
isActive ? 'bg-background' : 'bg-muted/50 opacity-70'
}`}
>
<div className="flex items-center gap-4">
<Switch
checked={isActive}
onCheckedChange={(checked) =>
saveOfflineSubscriptionMutation.mutate({
formId: form.id,
isActive: checked,
includeRelatedTasks: sub?.includeRelatedTasks ?? true,
includeFiles: sub?.includeFiles ?? false,
})
}
disabled={saveOfflineSubscriptionMutation.isPending}
/>
<div>
<div className="font-medium">{form.name}</div>
<div className="text-sm text-muted-foreground">
Макс. {form.offlineCache?.maxTasks ?? 500} задач ·{' '}
{form.offlineCache?.strategy === 'assigned'
? 'назначенные мне'
: form.offlineCache?.strategy === 'all'
? 'все задачи'
: 'последние по дате'}
</div>
</div>
</div>
{isActive && (
<div className="flex items-center gap-4 pl-12 md:pl-0">
<div className="flex items-center gap-2">
<Checkbox
checked={sub?.includeRelatedTasks ?? true}
onCheckedChange={(checked: boolean | 'indeterminate') =>
saveOfflineSubscriptionMutation.mutate({
formId: form.id,
isActive: true,
includeRelatedTasks: checked === true,
includeFiles: sub?.includeFiles ?? false,
})
}
disabled={saveOfflineSubscriptionMutation.isPending}
id={`offline-sub-related-${form.id}`}
/>
<Label htmlFor={`offline-sub-related-${form.id}`} className="text-sm cursor-pointer">
Связанные задачи
</Label>
</div>
<div className="flex items-center gap-2">
<Checkbox
checked={sub?.includeFiles ?? false}
onCheckedChange={(checked: boolean | 'indeterminate') =>
saveOfflineSubscriptionMutation.mutate({
formId: form.id,
isActive: true,
includeRelatedTasks: sub?.includeRelatedTasks ?? true,
includeFiles: checked === true,
})
}
disabled={saveOfflineSubscriptionMutation.isPending}
id={`offline-sub-files-${form.id}`}
/>
<Label htmlFor={`offline-sub-files-${form.id}`} className="text-sm cursor-pointer">
Файлы
</Label>
</div>
</div>
)}
</div>
);
})}
</div>
)}
</CardContent>
</Card>
</TabsContent>
<TabsContent value="security" className="space-y-6">
<Card>
<CardHeader>
<CardTitle>Смена пароля</CardTitle>
<CardDescription>
Введите текущий пароль и новый пароль для вашего аккаунта
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="current-password">Текущий пароль</Label>
<Input
id="current-password"
type="password"
autoComplete="current-password"
value={currentPassword}
onChange={e => setCurrentPassword(e.target.value)}
placeholder="Введите текущий пароль"
/>
</div>
<div className="space-y-2">
<Label htmlFor="new-password">Новый пароль</Label>
<Input
id="new-password"
type="password"
autoComplete="new-password"
value={newPassword}
onChange={e => {
setNewPassword(e.target.value);
setPasswordError(null);
}}
placeholder="Введите новый пароль"
aria-invalid={!!passwordError}
aria-describedby={passwordError ? 'new-password-error' : undefined}
/>
<p className="text-xs text-muted-foreground">
Минимум 8 символов, заглавная и строчная буква, цифра и специальный символ
</p>
</div>
<div className="space-y-2">
<Label htmlFor="confirm-password">Подтвердите новый пароль</Label>
<Input
id="confirm-password"
type="password"
autoComplete="new-password"
value={confirmPassword}
onChange={e => {
setConfirmPassword(e.target.value);
setPasswordError(null);
}}
placeholder="Повторите новый пароль"
/>
{passwordError && (
<p id="new-password-error" className="text-xs text-destructive">
{passwordError}
</p>
)}
</div>
<Button
onClick={() => {
if (!currentPassword || !newPassword || !confirmPassword) {
toast({ title: 'Заполните все поля', variant: 'destructive' });
return;
}
if (newPassword !== confirmPassword) {
setPasswordError('Новые пароли не совпадают');
toast({ title: 'Новые пароли не совпадают', variant: 'destructive' });
return;
}
// Показываем конкретное невыполненное правило, а не общий текст
const failedRules: string[] = [];
if (newPassword.length < 8) failedRules.push('минимум 8 символов');
if (!/[A-Z]/.test(newPassword)) failedRules.push('заглавную букву');
if (!/[a-z]/.test(newPassword)) failedRules.push('строчную букву');
if (!/\d/.test(newPassword)) failedRules.push('цифру');
if (!/[!@#$%^&*(),.?":{}|<>]/.test(newPassword)) failedRules.push('специальный символ');
if (failedRules.length > 0) {
const message = `Пароль должен содержать: ${failedRules.join(', ')}`;
setPasswordError(message);
toast({ title: message, variant: 'destructive' });
return;
}
changePasswordMutation.mutate({ currentPassword, newPassword });
}}
disabled={changePasswordMutation.isPending}
>
{changePasswordMutation.isPending ? (
<Loader2 className="w-4 h-4 animate-spin mr-2" />
) : (
<Lock className="w-4 h-4 mr-2" />
)}
Изменить пароль
</Button>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
</main>
</Layout>
);
};
export default ProfileSettings;

View File

@@ -20,7 +20,7 @@ import { apiRequest, queryClient } from '@/lib/queryClient';
import { authService } from '@/services/auth.service'; import { authService } from '@/services/auth.service';
import { normalizeSelectOptions } from '@/lib/fieldOptions'; import { normalizeSelectOptions } from '@/lib/fieldOptions';
import { SearchableMultiSelect } from '@/components/fields/SearchableMultiSelect'; import { SearchableMultiSelect } from '@/components/fields/SearchableMultiSelect';
import { ArrowLeft, Save, Loader2, User as UserIcon, Mail, Shield, Briefcase, FileText, History, Calendar, Clock, CheckCircle2, Circle, AlertCircle, UserCheck } from 'lucide-react'; import { ArrowLeft, Save, Loader2, User as UserIcon, Mail, Shield, Briefcase, FileText, History, Calendar, Clock, CheckCircle2, Circle, AlertCircle, UserCheck, Bell, WifiOff, Palette, Lock } from 'lucide-react';
import { Link } from 'wouter'; import { Link } from 'wouter';
import CompanyFieldInput from '@/components/CompanyFieldInput'; import CompanyFieldInput from '@/components/CompanyFieldInput';
import GeoField from '@/components/GeoField'; import GeoField from '@/components/GeoField';
@@ -28,6 +28,10 @@ import TableFieldSelect from '@/components/TableFieldSelect';
import TableFieldMultiSelect from '@/components/TableFieldMultiSelect'; import TableFieldMultiSelect from '@/components/TableFieldMultiSelect';
import { ContactListEditor } from '@/components/user/ContactListEditor'; import { ContactListEditor } from '@/components/user/ContactListEditor';
import ContactField from '@/components/ContactField'; import ContactField from '@/components/ContactField';
import { ProfileNotificationsTab } from '@/components/profile/ProfileNotificationsTab';
import { ProfileOfflineTab } from '@/components/profile/ProfileOfflineTab';
import { ProfileInterfaceTab } from '@/components/profile/ProfileInterfaceTab';
import { ProfileSecurityTab } from '@/components/profile/ProfileSecurityTab';
interface UserProfileTab { interface UserProfileTab {
id: number; id: number;
@@ -600,12 +604,16 @@ function UserProfileFieldInput({
} }
} }
export default function UserProfilePage() { export default function UserProfilePage({ userId: userIdProp }: { userId?: number; params?: { id?: string } } = {}) {
const params = useParams<{ id: string }>(); const params = useParams<{ id: string }>();
const userId = parseInt(params.id); // userId приходит пропом с маршрута /profile (свой профиль) или из URL /users/:id
const userId = userIdProp ?? parseInt(params.id);
const { toast } = useToast(); const { toast } = useToast();
const { user: currentUser } = useAuth(); const { user: currentUser } = useAuth();
const isSelf = currentUser?.id === userId;
const isAdminView = isAdmin(currentUser);
const { data, isLoading, error } = useQuery<UserProfileData>({ const { data, isLoading, error } = useQuery<UserProfileData>({
queryKey: ['/api/users', userId, 'profile'], queryKey: ['/api/users', userId, 'profile'],
queryFn: async () => { queryFn: async () => {
@@ -909,7 +917,7 @@ export default function UserProfilePage() {
</div> </div>
)} )}
</div> </div>
<Link href="/users"> <Link href={isSelf ? '/' : '/users'}>
<Button variant="outline" size="sm"> <Button variant="outline" size="sm">
<ArrowLeft className="w-4 h-4 mr-1" /> <ArrowLeft className="w-4 h-4 mr-1" />
Назад Назад
@@ -934,6 +942,33 @@ export default function UserProfilePage() {
<History className="w-3.5 h-3.5" /> <History className="w-3.5 h-3.5" />
История История
</TabsTrigger> </TabsTrigger>
{/* Уведомления и Offline — для себя и для админа на чужой карточке */}
{(isSelf || isAdminView) && (
<>
<TabsTrigger value="notifications" className="flex items-center gap-1">
<Bell className="w-3.5 h-3.5" />
Уведомления
</TabsTrigger>
<TabsTrigger value="offline" className="flex items-center gap-1">
<WifiOff className="w-3.5 h-3.5" />
Offline
</TabsTrigger>
</>
)}
{/* Интерфейс и Безопасность — только на своём профиле:
тема/скин локальны для устройства, смена пароля другому есть в «Основное» */}
{isSelf && (
<>
<TabsTrigger value="interface" className="flex items-center gap-1">
<Palette className="w-3.5 h-3.5" />
Интерфейс
</TabsTrigger>
<TabsTrigger value="security" className="flex items-center gap-1">
<Lock className="w-3.5 h-3.5" />
Безопасность
</TabsTrigger>
</>
)}
</TabsList> </TabsList>
{/* System tab */} {/* System tab */}
@@ -990,7 +1025,7 @@ export default function UserProfilePage() {
<Select <Select
value={systemForm.watch('appRole')} value={systemForm.watch('appRole')}
onValueChange={(v) => systemForm.setValue('appRole', v)} onValueChange={(v) => systemForm.setValue('appRole', v)}
disabled={!canEdit} disabled={!isAdminView}
> >
<SelectTrigger> <SelectTrigger>
<SelectValue /> <SelectValue />
@@ -1006,7 +1041,7 @@ export default function UserProfilePage() {
id="isActive" id="isActive"
checked={systemForm.watch('isActive')} checked={systemForm.watch('isActive')}
onCheckedChange={(c) => systemForm.setValue('isActive', !!c)} onCheckedChange={(c) => systemForm.setValue('isActive', !!c)}
disabled={!canEdit} disabled={!isAdminView}
/> />
<Label htmlFor="isActive">Активен</Label> <Label htmlFor="isActive">Активен</Label>
</div> </div>
@@ -1015,7 +1050,7 @@ export default function UserProfilePage() {
<Select <Select
value={systemForm.watch('statusId') ? String(systemForm.watch('statusId')) : 'none'} value={systemForm.watch('statusId') ? String(systemForm.watch('statusId')) : 'none'}
onValueChange={(v) => systemForm.setValue('statusId', v === 'none' ? null : Number(v))} onValueChange={(v) => systemForm.setValue('statusId', v === 'none' ? null : Number(v))}
disabled={!canEdit} disabled={!isAdminView}
> >
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="Выберите статус" /> <SelectValue placeholder="Выберите статус" />
@@ -1253,6 +1288,29 @@ export default function UserProfilePage() {
<HistoryTab auditLog={auditLogData?.auditLog || []} /> <HistoryTab auditLog={auditLogData?.auditLog || []} />
)} )}
</TabsContent> </TabsContent>
{/* Уведомления и Offline — свой профиль или админский просмотр чужого */}
{(isSelf || isAdminView) && (
<>
<TabsContent value="notifications" className="space-y-4">
<ProfileNotificationsTab userId={userId} />
</TabsContent>
<TabsContent value="offline" className="space-y-4">
<ProfileOfflineTab userId={userId} isSelf={isSelf} />
</TabsContent>
</>
)}
{/* Интерфейс и Безопасность — только на своём профиле */}
{isSelf && (
<>
<TabsContent value="interface" className="space-y-4">
<ProfileInterfaceTab userId={userId} />
</TabsContent>
<TabsContent value="security" className="space-y-4">
<ProfileSecurityTab />
</TabsContent>
</>
)}
</Tabs> </Tabs>
</div> </div>
</Layout> </Layout>

View File

@@ -5,6 +5,7 @@ import { tenantIsolation } from "../middleware/tenant.middleware";
import { validateRequest } from "../middleware/validation.middleware"; import { validateRequest } from "../middleware/validation.middleware";
import { createSubscriptionSchema, updateSubscriptionSchema } from "@shared/schema"; import { createSubscriptionSchema, updateSubscriptionSchema } from "@shared/schema";
import { notificationService } from "../services/notification.service"; import { notificationService } from "../services/notification.service";
import { canManageUser } from "../utils/user-access";
export function registerChatNotificationRoutes(router: Router): void { export function registerChatNotificationRoutes(router: Router): void {
// Get user notifications // Get user notifications
@@ -97,13 +98,27 @@ export function registerChatNotificationRoutes(router: Router): void {
); );
// Get user subscriptions // Get user subscriptions
// ?userId=N — подписки другого пользователя (только при canManageUser: админ/руководитель)
router.get('/api/subscriptions', router.get('/api/subscriptions',
authenticateToken, authenticateToken,
tenantIsolation, tenantIsolation,
async (req: AuthenticatedRequest, res) => { async (req: AuthenticatedRequest, res) => {
try { try {
let targetUserId = req.user!.id;
if (req.query.userId !== undefined) {
const queryUserId = parseInt(String(req.query.userId));
if (isNaN(queryUserId)) {
return res.status(400).json({ success: false, error: 'Некорректный ID пользователя' });
}
if (queryUserId !== req.user!.id) {
if (!(await canManageUser(req.user, queryUserId, req.organizationId!))) {
return res.status(403).json({ success: false, error: 'Нет прав на просмотр подписок этого пользователя' });
}
targetUserId = queryUserId;
}
}
const subscriptions = await notificationService.getUserSubscriptions( const subscriptions = await notificationService.getUserSubscriptions(
req.user!.id, req.organizationId! targetUserId, req.organizationId!
); );
res.json({ success: true, subscriptions }); res.json({ success: true, subscriptions });
} catch (error) { } catch (error) {
@@ -114,14 +129,22 @@ export function registerChatNotificationRoutes(router: Router): void {
); );
// Create subscription // Create subscription
// body.userId — создать подписку другому пользователю (только при canManageUser)
router.post('/api/subscriptions', router.post('/api/subscriptions',
authenticateToken, authenticateToken,
tenantIsolation, tenantIsolation,
validateRequest(createSubscriptionSchema), validateRequest(createSubscriptionSchema),
async (req: AuthenticatedRequest, res) => { async (req: AuthenticatedRequest, res) => {
try { try {
let targetUserId = req.user!.id;
if (req.body.userId !== undefined && req.body.userId !== req.user!.id) {
if (!(await canManageUser(req.user, req.body.userId, req.organizationId!))) {
return res.status(403).json({ success: false, error: 'Нет прав на управление подписками этого пользователя' });
}
targetUserId = req.body.userId;
}
const subscription = await notificationService.createSubscription({ const subscription = await notificationService.createSubscription({
userId: req.user!.id, userId: targetUserId,
organizationId: req.organizationId!, organizationId: req.organizationId!,
eventTypeCode: req.body.eventTypeCode, eventTypeCode: req.body.eventTypeCode,
targetType: req.body.targetType, targetType: req.body.targetType,
@@ -147,8 +170,17 @@ export function registerChatNotificationRoutes(router: Router): void {
if (isNaN(subscriptionId)) { if (isNaN(subscriptionId)) {
return res.status(400).json({ success: false, error: 'Некорректный ID подписки' }); return res.status(400).json({ success: false, error: 'Некорректный ID подписки' });
} }
const existing = await notificationService.getSubscriptionById(subscriptionId, req.organizationId!);
if (!existing) {
return res.status(404).json({ success: false, error: 'Подписка не найдена' });
}
// Владелец подписки или canManageUser над владельцем (админ/руководитель)
if (existing.userId !== req.user!.id &&
!(await canManageUser(req.user, existing.userId, req.organizationId!))) {
return res.status(403).json({ success: false, error: 'Нет прав на изменение этой подписки' });
}
const subscription = await notificationService.updateSubscription( const subscription = await notificationService.updateSubscription(
subscriptionId, req.user!.id, req.organizationId!, req.body subscriptionId, existing.userId, req.organizationId!, req.body
); );
if (!subscription) { if (!subscription) {
return res.status(404).json({ success: false, error: 'Подписка не найдена' }); return res.status(404).json({ success: false, error: 'Подписка не найдена' });
@@ -171,7 +203,16 @@ export function registerChatNotificationRoutes(router: Router): void {
if (isNaN(subscriptionId)) { if (isNaN(subscriptionId)) {
return res.status(400).json({ success: false, error: 'Некорректный ID подписки' }); return res.status(400).json({ success: false, error: 'Некорректный ID подписки' });
} }
await notificationService.deleteSubscription(subscriptionId, req.user!.id, req.organizationId!); const existing = await notificationService.getSubscriptionById(subscriptionId, req.organizationId!);
if (!existing) {
return res.status(404).json({ success: false, error: 'Подписка не найдена' });
}
// Владелец подписки или canManageUser над владельцем (админ/руководитель)
if (existing.userId !== req.user!.id &&
!(await canManageUser(req.user, existing.userId, req.organizationId!))) {
return res.status(403).json({ success: false, error: 'Нет прав на удаление этой подписки' });
}
await notificationService.deleteSubscription(subscriptionId, existing.userId, req.organizationId!);
res.json({ success: true, message: 'Подписка удалена' }); res.json({ success: true, message: 'Подписка удалена' });
} catch (error) { } catch (error) {
console.error('Delete subscription error:', error); console.error('Delete subscription error:', error);

View File

@@ -4,6 +4,7 @@ import { storage } from "../storage";
import { authenticateToken, type AuthenticatedRequest } from "../middleware/auth.middleware"; import { authenticateToken, type AuthenticatedRequest } from "../middleware/auth.middleware";
import { tenantIsolation } from "../middleware/tenant.middleware"; import { tenantIsolation } from "../middleware/tenant.middleware";
import { logAudit } from "../utils/audit"; import { logAudit } from "../utils/audit";
import { canManageUser } from "../utils/user-access";
import { z } from "zod"; import { z } from "zod";
const OFFLINE_TASK_LIMIT = 500; const OFFLINE_TASK_LIMIT = 500;
@@ -33,12 +34,26 @@ export function registerSyncRoutes(app: import("express").Express): void {
* - формы с включённым глобальным offlineCache * - формы с включённым глобальным offlineCache
* - персональные подписки пользователя * - персональные подписки пользователя
* - автоматизации, разрешённые для офлайн-выполнения * - автоматизации, разрешённые для офлайн-выполнения
* ?userId=N — конфигурация другого пользователя (только при canManageUser: админ/руководитель)
*/ */
router.get('/config', async (req: AuthenticatedRequest, res) => { router.get('/config', async (req: AuthenticatedRequest, res) => {
try { try {
const userId = req.user!.id; let userId = req.user!.id;
const organizationId = req.organizationId!; const organizationId = req.organizationId!;
if (req.query.userId !== undefined) {
const queryUserId = parseInt(String(req.query.userId));
if (isNaN(queryUserId)) {
return res.status(400).json({ success: false, error: 'Некорректный ID пользователя' });
}
if (queryUserId !== userId) {
if (!(await canManageUser(req.user, queryUserId, organizationId))) {
return res.status(403).json({ success: false, error: 'Нет прав на просмотр конфигурации этого пользователя' });
}
userId = queryUserId;
}
}
const [globalForms, subscriptions, automations] = await Promise.all([ const [globalForms, subscriptions, automations] = await Promise.all([
storage.getFormsWithOfflineCache(organizationId), storage.getFormsWithOfflineCache(organizationId),
storage.getUserOfflineSubscriptions(userId, organizationId), storage.getUserOfflineSubscriptions(userId, organizationId),

View File

@@ -490,6 +490,19 @@ export class NotificationService {
); );
} }
async getSubscriptionById(id: number, organizationId: number): Promise<NotificationSubscription | null> {
const [subscription] = await db
.select()
.from(notificationSubscriptions)
.where(
and(
eq(notificationSubscriptions.id, id),
eq(notificationSubscriptions.organizationId, organizationId)
)
);
return subscription || null;
}
async createSubscription(data: { async createSubscription(data: {
userId: number; userId: number;
organizationId: number; organizationId: number;

View File

@@ -2252,6 +2252,8 @@ export const createSubscriptionSchema = z.object({
targetType: z.enum(['form', 'task', 'all']).optional(), targetType: z.enum(['form', 'task', 'all']).optional(),
targetId: z.number().positive().optional(), targetId: z.number().positive().optional(),
channels: z.array(z.enum(['app', 'email', 'push'])).default(['app']), channels: z.array(z.enum(['app', 'email', 'push'])).default(['app']),
// Целевой пользователь (только для админа/руководителя, проверка canManageUser в роуте)
userId: z.number().int().positive().optional(),
}); });
export const updateSubscriptionSchema = z.object({ export const updateSubscriptionSchema = z.object({