diff --git a/backend/notifications/api/serializers.py b/backend/notifications/api/serializers.py index 0dc477d2..5048c523 100644 --- a/backend/notifications/api/serializers.py +++ b/backend/notifications/api/serializers.py @@ -174,6 +174,7 @@ class SubscribePushRequestSerializer(serializers.Serializer): class UnsubscribePushRequestSerializer(serializers.Serializer): endpoint = serializers.CharField(required=False) + device_group_id = serializers.UUIDField(required=False) class InstallationHeartbeatRequestSerializer(serializers.Serializer): diff --git a/backend/notifications/api/views.py b/backend/notifications/api/views.py index f0406145..ddb782a0 100644 --- a/backend/notifications/api/views.py +++ b/backend/notifications/api/views.py @@ -924,7 +924,10 @@ def subscribe_push(request): 'user': request.user, 'p256dh': keys.get('p256dh', ''), 'auth': keys.get('auth', ''), - 'browser': device_name, + # django-push-notifications keeps a legacy browser code in a + # varchar(10). The human-readable device name belongs to + # NotificationInstallation.device_name and may be much longer. + 'browser': _web_push_browser(request), 'active': True, } ) @@ -1181,7 +1184,10 @@ def unsubscribe_push(request): """Отписка от Web Push уведомлений""" from push_notifications.models import WebPushDevice - endpoint = request.data.get('endpoint') + serializer = UnsubscribePushRequestSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + endpoint = serializer.validated_data.get('endpoint') + device_group_id = serializer.validated_data.get('device_group_id') if endpoint: NotificationInstallation.objects.filter( @@ -1196,6 +1202,29 @@ def unsubscribe_push(request): user=request.user, registration_id=endpoint ).delete() + elif device_group_id: + # Logout is scoped to the current physical device. A user may still + # have active subscriptions on a phone, another browser, or a PWA. + installations = NotificationInstallation.objects.filter( + user=request.user, + device_group_id=device_group_id, + ) + endpoints = list( + installations.exclude(endpoint__isnull=True).values_list( + 'endpoint', + flat=True, + ) + ) + deleted_count, _ = WebPushDevice.objects.filter( + user=request.user, + registration_id__in=endpoints, + ).delete() + installations.update( + endpoint=None, + push_device_id=None, + active=False, + last_seen_at=timezone.now(), + ) else: # Удалить все устройства пользователя deleted_count, _ = WebPushDevice.objects.filter( @@ -1219,3 +1248,17 @@ def unsubscribe_push(request): 'message': f'Deleted {deleted_count} push subscription(s)', 'deleted_count': deleted_count }) + + +def _web_push_browser(request): + """Return a value accepted by WebPushDevice.browser (max length 10).""" + user_agent = request.META.get('HTTP_USER_AGENT', '').lower() + if 'firefox' in user_agent: + return 'FIREFOX' + if 'edg/' in user_agent or 'edge/' in user_agent: + return 'EDGE' + if 'opr/' in user_agent or 'opera' in user_agent: + return 'OPERA' + # The library has no Safari choice. For modern HTTPS endpoints this + # legacy field is not used to construct the delivery URL. + return 'CHROME' diff --git a/backend/tests/api/v1/notifications/test_notifications_api.py b/backend/tests/api/v1/notifications/test_notifications_api.py index 9e862cba..24283b69 100644 --- a/backend/tests/api/v1/notifications/test_notifications_api.py +++ b/backend/tests/api/v1/notifications/test_notifications_api.py @@ -628,9 +628,92 @@ class TestNotificationPreferencesApi: assert response.status_code == 200 installation = NotificationInstallation.objects.get(user=user) + device = WebPushDevice.objects.get(pk=installation.push_device_id) assert installation.app_kind == "messenger" + assert installation.device_name == "Safari iPhone" assert installation.installed is True assert installation.push_ready is True + assert device.browser == "CHROME" + + def test_subscribe_push_normalizes_browser_from_user_agent( + self, api_client, user_factory + ): + user = user_factory() + api_client.force_authenticate(user=user) + + response = api_client.post( + "/api/v1/notifications/push/subscribe/", + { + "endpoint": "https://push.example.test/long-device-name", + "keys": {"p256dh": "test-p256dh", "auth": "test-auth"}, + "device_name": "Linux x86_64 · Desktop", + }, + format="json", + HTTP_USER_AGENT="Mozilla/5.0 Firefox/142.0", + ) + + assert response.status_code == 200 + device = WebPushDevice.objects.get(user=user) + installation = NotificationInstallation.objects.get(user=user) + assert device.browser == "FIREFOX" + assert installation.device_name == "Linux x86_64 · Desktop" + + def test_unsubscribe_device_group_does_not_remove_other_device( + self, api_client, user_factory + ): + user = user_factory() + api_client.force_authenticate(user=user) + current_group = "084127c8-ac7a-47c8-92c5-d7d41713b150" + other_group = "73d2d87f-2cd7-44ca-a658-d7d41713a898" + current_device = WebPushDevice.objects.create( + user=user, + registration_id="https://push.example.test/current-device", + p256dh="test-p256dh", + auth="test-auth", + browser="CHROME", + active=True, + ) + other_device = WebPushDevice.objects.create( + user=user, + registration_id="https://push.example.test/other-device", + p256dh="test-p256dh", + auth="test-auth", + browser="CHROME", + active=True, + ) + current_installation = NotificationInstallation.objects.create( + user=user, + device_group_id=current_group, + app_kind="main", + endpoint=current_device.registration_id, + push_device_id=current_device.id, + installed=True, + ) + other_installation = NotificationInstallation.objects.create( + user=user, + device_group_id=other_group, + app_kind="main", + endpoint=other_device.registration_id, + push_device_id=other_device.id, + installed=True, + ) + + response = api_client.delete( + "/api/v1/notifications/push/unsubscribe/", + {"device_group_id": current_group}, + format="json", + ) + + assert response.status_code == 200 + assert response.data["deleted_count"] == 1 + current_installation.refresh_from_db() + other_installation.refresh_from_db() + assert current_installation.active is False + assert current_installation.endpoint is None + assert current_installation.push_device_id is None + assert other_installation.active is True + assert other_installation.endpoint == other_device.registration_id + assert WebPushDevice.objects.filter(pk=other_device.pk).exists() def test_delivery_routing_requires_ready_messenger( self, api_client, user_factory diff --git a/frontend/src/contexts/UserContext.tsx b/frontend/src/contexts/UserContext.tsx index 15261bdf..d08f9b29 100644 --- a/frontend/src/contexts/UserContext.tsx +++ b/frontend/src/contexts/UserContext.tsx @@ -11,6 +11,8 @@ import { } from "@/lib/api/client"; import type { User } from "@/types/api"; import { useRouter } from "next/navigation"; +import { unsubscribeAllLocalPushSubscriptions } from "@/lib/push"; +import { getDeviceGroupId } from "@/lib/notifications/installations"; export type AuthStatus = 'loading' | 'authenticated' | 'recovering' | 'unauthenticated'; @@ -21,7 +23,7 @@ interface UserContextType { error: string | null; refreshUser: () => Promise; retryAuthentication: () => Promise; - logout: () => void; + logout: () => Promise; } const UserContext = createContext(undefined); @@ -111,12 +113,26 @@ export function UserProvider({ children }: { children: ReactNode }) { await loadUser(); }, [loadUser]); - const logout = useCallback(() => { + const logout = useCallback(async () => { clearRetryTimer(); - apiClient.clearToken('logout'); - setUser(null); - setStatus('unauthenticated'); - router.replace('/login'); + try { + const cleanupResults = await Promise.allSettled([ + apiClient.unsubscribePushForDeviceGroup(getDeviceGroupId()), + unsubscribeAllLocalPushSubscriptions(), + ]); + cleanupResults.forEach((result) => { + if (result.status === 'rejected') { + console.warn('[logout] Не удалось полностью очистить push-подписку:', result.reason); + } + }); + } catch (caught) { + console.warn('[logout] Не удалось запустить очистку push-подписки:', caught); + } finally { + apiClient.clearToken('logout'); + setUser(null); + setStatus('unauthenticated'); + router.replace('/login'); + } }, [clearRetryTimer, router]); useEffect(() => { @@ -134,6 +150,9 @@ export function UserProvider({ children }: { children: ReactNode }) { const handleAuthEvent = (event: Event) => { const detail = (event as CustomEvent).detail; if (detail?.type === 'session-cleared') { + void unsubscribeAllLocalPushSubscriptions().catch((caught) => { + console.warn('[auth] Не удалось удалить локальную push-подписку:', caught); + }); clearRetryTimer(); setUser(null); setStatus('unauthenticated'); diff --git a/frontend/src/lib/api/notifications.ts b/frontend/src/lib/api/notifications.ts index 28a3eb11..e3051cf2 100644 --- a/frontend/src/lib/api/notifications.ts +++ b/frontend/src/lib/api/notifications.ts @@ -69,6 +69,12 @@ export function createNotificationsApi(request: RequestFn) { request('/api/v1/notifications/push/subscribe/', { method: 'POST', body: JSON.stringify(data) }), unsubscribePush: (endpoint?: string): Promise<{ status: string; message: string }> => request('/api/v1/notifications/push/unsubscribe/', { method: 'DELETE', body: JSON.stringify({ endpoint }) }), + unsubscribePushForDeviceGroup: (deviceGroupId: string): Promise<{ status: string; message: string }> => + request('/api/v1/notifications/push/unsubscribe/', { + method: 'DELETE', + body: JSON.stringify({ device_group_id: deviceGroupId }), + timeoutMs: 5_000, + }), heartbeatNotificationInstallation: (data: { installation_id: string; device_group_id: string; diff --git a/frontend/src/lib/push.ts b/frontend/src/lib/push.ts index ebcc85c8..84afba78 100644 --- a/frontend/src/lib/push.ts +++ b/frontend/src/lib/push.ts @@ -123,6 +123,39 @@ export async function unsubscribeFromPush( return success; } +/** + * Remove every Web Push subscription owned by this origin in the browser. + * This is intentionally local-only so it can also run after an auth session + * expires and the API can no longer authenticate the user. + */ +export async function unsubscribeAllLocalPushSubscriptions(): Promise { + if (typeof navigator === 'undefined' || !('serviceWorker' in navigator)) { + return 0; + } + + const registrations = await navigator.serviceWorker.getRegistrations(); + const shownNotifications = await Promise.all( + registrations.map((registration) => + registration.getNotifications().catch(() => []), + ), + ); + shownNotifications.flat().forEach((notification) => notification.close()); + + const subscriptions = await Promise.all( + registrations.map((registration) => registration.pushManager.getSubscription()), + ); + const activeSubscriptions = subscriptions.filter( + (subscription): subscription is PushSubscription => subscription !== null, + ); + const results = await Promise.allSettled( + activeSubscriptions.map((subscription) => subscription.unsubscribe()), + ); + + return results.filter( + (result) => result.status === 'fulfilled' && result.value, + ).length; +} + /** * Получает текущую push подписку */