Scope push logout cleanup to device groups
This commit is contained in:
parent
f4795922fe
commit
4ea807ee4b
@ -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):
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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<void>;
|
||||
retryAuthentication: () => Promise<void>;
|
||||
logout: () => void;
|
||||
logout: () => Promise<void>;
|
||||
}
|
||||
|
||||
const UserContext = createContext<UserContextType | undefined>(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<AuthSessionEventDetail>).detail;
|
||||
if (detail?.type === 'session-cleared') {
|
||||
void unsubscribeAllLocalPushSubscriptions().catch((caught) => {
|
||||
console.warn('[auth] Не удалось удалить локальную push-подписку:', caught);
|
||||
});
|
||||
clearRetryTimer();
|
||||
setUser(null);
|
||||
setStatus('unauthenticated');
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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<number> {
|
||||
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 подписку
|
||||
*/
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user