chore: merge upstream (#2024)

This commit is contained in:
Gauthier
2025-10-09 05:34:31 +02:00
committed by GitHub
parent f292d93d10
commit 34fcc5d2c7
6 changed files with 356 additions and 150 deletions

View File

@@ -1,10 +1,14 @@
/* eslint-disable no-console */
import useSettings from '@app/hooks/useSettings';
import { useUser } from '@app/hooks/useUser';
import { verifyAndResubscribePushSubscription } from '@app/utils/pushSubscriptionHelpers';
import { useEffect } from 'react';
const ServiceWorkerSetup = () => {
const { user } = useUser();
const { currentSettings } = useSettings();
useEffect(() => {
if ('serviceWorker' in navigator && user?.id) {
navigator.serviceWorker
@@ -14,12 +18,53 @@ const ServiceWorkerSetup = () => {
'[SW] Registration successful, scope is:',
registration.scope
);
const pushNotificationsEnabled =
localStorage.getItem('pushNotificationsEnabled') === 'true';
// Reset the notifications flag if permissions were revoked
if (
Notification.permission !== 'granted' &&
pushNotificationsEnabled
) {
localStorage.setItem('pushNotificationsEnabled', 'false');
console.warn(
'[SW] Push permissions not granted — skipping resubscribe'
);
return;
}
// Bypass resubscribing if we have manually disabled push notifications
if (!pushNotificationsEnabled) {
return;
}
const subscription = await registration.pushManager.getSubscription();
console.log(
'[SW] Existing push subscription:',
subscription?.endpoint
);
const verified = await verifyAndResubscribePushSubscription(
user.id,
currentSettings
);
if (verified) {
console.log('[SW] Push subscription verified or refreshed.');
} else {
console.warn(
'[SW] Push subscription verification failed or not available.'
);
}
})
.catch(function (error) {
console.log('[SW] Service worker registration failed, error:', error);
});
}
}, [user]);
}, [currentSettings, user]);
return null;
};

View File

@@ -1,16 +1,18 @@
import Button from '@app/components/Common/Button';
import ConfirmButton from '@app/components/Common/ConfirmButton';
import globalMessages from '@app/i18n/globalMessages';
import defineMessages from '@app/utils/defineMessages';
import {
ComputerDesktopIcon,
DevicePhoneMobileIcon,
LockClosedIcon,
TrashIcon,
} from '@heroicons/react/24/solid';
import { useIntl } from 'react-intl';
import { UAParser } from 'ua-parser-js';
interface DeviceItemProps {
disablePushNotifications: (p256dh: string) => void;
deletePushSubscriptionFromBackend: (endpoint: string) => void;
device: {
endpoint: string;
p256dh: string;
@@ -18,6 +20,7 @@ interface DeviceItemProps {
userAgent: string;
createdAt: Date;
};
subEndpoint: string | null;
}
const messages = defineMessages(
@@ -28,10 +31,15 @@ const messages = defineMessages(
engine: 'Engine',
deletesubscription: 'Delete Subscription',
unknown: 'Unknown',
activesubscription: 'Active Subscription',
}
);
const DeviceItem = ({ disablePushNotifications, device }: DeviceItemProps) => {
const DeviceItem = ({
deletePushSubscriptionFromBackend,
device,
subEndpoint,
}: DeviceItemProps) => {
const intl = useIntl();
const parsedUserAgent = UAParser(device.userAgent);
@@ -91,14 +99,21 @@ const DeviceItem = ({ disablePushNotifications, device }: DeviceItemProps) => {
</div>
</div>
<div className="z-10 mt-4 flex w-full flex-col justify-center space-y-2 pl-4 pr-4 xl:mt-0 xl:w-96 xl:items-end xl:pl-0">
<ConfirmButton
onClick={() => disablePushNotifications(device.endpoint)}
confirmText={intl.formatMessage(globalMessages.areyousure)}
className="w-full"
>
<TrashIcon />
<span>{intl.formatMessage(messages.deletesubscription)}</span>
</ConfirmButton>
{subEndpoint === device.endpoint ? (
<Button buttonType="primary" className="w-full" disabled>
<LockClosedIcon />{' '}
<span>{intl.formatMessage(messages.activesubscription)}</span>
</Button>
) : (
<ConfirmButton
onClick={() => deletePushSubscriptionFromBackend(device.endpoint)}
confirmText={intl.formatMessage(globalMessages.areyousure)}
className="w-full"
>
<TrashIcon />
<span>{intl.formatMessage(messages.deletesubscription)}</span>
</ConfirmButton>
)}
</div>
</div>
);

View File

@@ -9,17 +9,22 @@ import useSettings from '@app/hooks/useSettings';
import { useUser } from '@app/hooks/useUser';
import globalMessages from '@app/i18n/globalMessages';
import defineMessages from '@app/utils/defineMessages';
import {
getPushSubscription,
subscribeToPushNotifications,
unsubscribeToPushNotifications,
verifyPushSubscription,
} from '@app/utils/pushSubscriptionHelpers';
import { ArrowDownOnSquareIcon } from '@heroicons/react/24/outline';
import {
CloudArrowDownIcon,
CloudArrowUpIcon,
} from '@heroicons/react/24/solid';
import type { UserPushSubscription } from '@server/entity/UserPushSubscription';
import type { UserSettingsNotificationsResponse } from '@server/interfaces/api/userSettingsInterfaces';
import axios from 'axios';
import { Form, Formik } from 'formik';
import { useRouter } from 'next/router';
import { useEffect, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { useIntl } from 'react-intl';
import { useToasts } from 'react-toast-notifications';
import useSWR, { mutate } from 'swr';
@@ -53,6 +58,7 @@ const UserWebPushSettings = () => {
const { user } = useUser({ id: Number(router.query.userId) });
const { currentSettings } = useSettings();
const [webPushEnabled, setWebPushEnabled] = useState(false);
const [subEndpoint, setSubEndpoint] = useState<string | null>(null);
const {
data,
error,
@@ -72,141 +78,122 @@ const UserWebPushSettings = () => {
// Subscribes to the push manager
// Will only add to the database if subscribing for the first time
const enablePushNotifications = () => {
if ('serviceWorker' in navigator && user?.id) {
navigator.serviceWorker
.getRegistration('/sw.js')
.then(async (registration) => {
if (currentSettings.enablePushRegistration) {
const sub = await registration?.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: currentSettings.vapidPublic,
});
const parsedSub = JSON.parse(JSON.stringify(sub));
const enablePushNotifications = async () => {
try {
const isSubscribed = await subscribeToPushNotifications(
user?.id,
currentSettings
);
if (parsedSub.keys.p256dh && parsedSub.keys.auth) {
await axios.post('/api/v1/user/registerPushSubscription', {
endpoint: parsedSub.endpoint,
p256dh: parsedSub.keys.p256dh,
auth: parsedSub.keys.auth,
userAgent: navigator.userAgent,
});
setWebPushEnabled(true);
addToast(intl.formatMessage(messages.webpushhasbeenenabled), {
appearance: 'success',
autoDismiss: true,
});
}
}
})
.catch(function () {
addToast(intl.formatMessage(messages.enablingwebpusherror), {
autoDismiss: true,
appearance: 'error',
});
})
.finally(function () {
revalidateDevices();
if (isSubscribed) {
localStorage.setItem('pushNotificationsEnabled', 'true');
setWebPushEnabled(true);
addToast(intl.formatMessage(messages.webpushhasbeenenabled), {
appearance: 'success',
autoDismiss: true,
});
} else {
throw new Error('Subscription failed');
}
} catch (error) {
addToast(intl.formatMessage(messages.enablingwebpusherror), {
appearance: 'error',
autoDismiss: true,
});
} finally {
revalidateDevices();
}
};
// Unsubscribes from the push manager
// Deletes/disables corresponding push subscription from database
const disablePushNotifications = async (endpoint?: string) => {
if ('serviceWorker' in navigator && user?.id) {
navigator.serviceWorker.getRegistration('/sw.js').then((registration) => {
registration?.pushManager
.getSubscription()
.then(async (subscription) => {
const parsedSub = JSON.parse(JSON.stringify(subscription));
try {
await unsubscribeToPushNotifications(user?.id, endpoint);
await axios.delete(
`/api/v1/user/${user.id}/pushSubscription/${encodeURIComponent(
endpoint ?? parsedSub.endpoint
)}`
);
if (
subscription &&
(endpoint === parsedSub.endpoint || !endpoint)
) {
subscription.unsubscribe();
setWebPushEnabled(false);
}
addToast(
intl.formatMessage(
endpoint
? messages.subscriptiondeleted
: messages.webpushhasbeendisabled
),
{
autoDismiss: true,
appearance: 'success',
}
);
})
.catch(function () {
addToast(
intl.formatMessage(
endpoint
? messages.subscriptiondeleteerror
: messages.disablingwebpusherror
),
{
autoDismiss: true,
appearance: 'error',
}
);
})
.finally(function () {
revalidateDevices();
});
localStorage.setItem('pushNotificationsEnabled', 'false');
setWebPushEnabled(false);
addToast(intl.formatMessage(messages.webpushhasbeendisabled), {
autoDismiss: true,
appearance: 'success',
});
} catch (error) {
addToast(intl.formatMessage(messages.disablingwebpusherror), {
autoDismiss: true,
appearance: 'error',
});
} finally {
revalidateDevices();
}
};
// Checks our current subscription on page load
// Will set the web push state to true if subscribed
useEffect(() => {
if ('serviceWorker' in navigator && user?.id) {
navigator.serviceWorker
.getRegistration('/sw.js')
.then(async (registration) => {
await registration?.pushManager
.getSubscription()
.then(async (subscription) => {
if (subscription) {
const parsedKey = JSON.parse(JSON.stringify(subscription));
const currentUserPushSub =
await axios.get<UserPushSubscription>(
`/api/v1/user/${
user.id
}/pushSubscription/${encodeURIComponent(
parsedKey.endpoint
)}`
);
const deletePushSubscriptionFromBackend = async (endpoint: string) => {
try {
await axios.delete(
`/api/v1/user/${user?.id}/pushSubscription/${encodeURIComponent(
endpoint
)}`
);
if (currentUserPushSub.data.endpoint !== parsedKey.endpoint) {
return;
}
setWebPushEnabled(true);
} else {
setWebPushEnabled(false);
}
});
})
.catch(function (error) {
setWebPushEnabled(false);
// eslint-disable-next-line no-console
console.log(
'[SW] Failure retrieving push manager subscription, error:',
error
);
});
addToast(intl.formatMessage(messages.subscriptiondeleted), {
autoDismiss: true,
appearance: 'success',
});
} catch (error) {
addToast(intl.formatMessage(messages.subscriptiondeleteerror), {
autoDismiss: true,
appearance: 'error',
});
} finally {
revalidateDevices();
}
}, [user?.id]);
};
useEffect(() => {
const verifyWebPush = async () => {
const enabled = await verifyPushSubscription(user?.id, currentSettings);
setWebPushEnabled(enabled);
};
if (user?.id) {
verifyWebPush();
}
}, [user?.id, currentSettings]);
useEffect(() => {
const getSubscriptionEndpoint = async () => {
if ('serviceWorker' in navigator && 'PushManager' in window) {
const { subscription } = await getPushSubscription();
if (subscription) {
setSubEndpoint(subscription.endpoint);
} else {
setSubEndpoint(null);
}
}
};
getSubscriptionEndpoint();
}, [webPushEnabled]);
const sortedDevices = useMemo(() => {
if (!dataDevices || !subEndpoint) {
return dataDevices;
}
return [...dataDevices].sort((a, b) => {
if (a.endpoint === subEndpoint) {
return -1;
}
if (b.endpoint === subEndpoint) {
return 1;
}
const dateA = a.createdAt ? new Date(a.createdAt).getTime() : 0;
const dateB = b.createdAt ? new Date(b.createdAt).getTime() : 0;
return dateB - dateA;
});
}, [dataDevices, subEndpoint]);
if (!data && !error) {
return <LoadingSpinner />;
@@ -324,22 +311,18 @@ const UserWebPushSettings = () => {
{intl.formatMessage(messages.managedevices)}
</h3>
<div className="section">
{dataDevices?.length ? (
dataDevices
?.sort((a, b) => {
const dateA = a.createdAt ? new Date(a.createdAt).getTime() : 0;
const dateB = b.createdAt ? new Date(b.createdAt).getTime() : 0;
return dateB - dateA;
})
.map((device, index) => (
<div className="py-2" key={`device-list-${index}`}>
<DeviceItem
key={index}
disablePushNotifications={disablePushNotifications}
device={device}
/>
</div>
))
{sortedDevices?.length ? (
sortedDevices.map((device) => (
<div className="py-2" key={`device-list-${device.endpoint}`}>
<DeviceItem
deletePushSubscriptionFromBackend={
deletePushSubscriptionFromBackend
}
device={device}
subEndpoint={subEndpoint}
/>
</div>
))
) : (
<>
<Alert

View File

@@ -1436,6 +1436,7 @@
"components.UserProfile.UserSettings.UserLinkedAccountsSettings.noPermissionDescription": "You do not have permission to modify this user's linked accounts.",
"components.UserProfile.UserSettings.UserLinkedAccountsSettings.plexErrorExists": "This account is already linked to a Plex user",
"components.UserProfile.UserSettings.UserLinkedAccountsSettings.plexErrorUnauthorized": "Unable to connect to Plex using your credentials",
"components.UserProfile.UserSettings.UserNotificationSettings.UserNotificationsWebPush.activesubscription": "Active Subscription",
"components.UserProfile.UserSettings.UserNotificationSettings.UserNotificationsWebPush.browser": "Browser",
"components.UserProfile.UserSettings.UserNotificationSettings.UserNotificationsWebPush.created": "Created",
"components.UserProfile.UserSettings.UserNotificationSettings.UserNotificationsWebPush.deletesubscription": "Delete Subscription",

View File

@@ -0,0 +1,162 @@
import type { UserPushSubscription } from '@server/entity/UserPushSubscription';
import type { PublicSettingsResponse } from '@server/interfaces/api/settingsInterfaces';
import axios from 'axios';
// Taken from https://www.npmjs.com/package/web-push
function urlBase64ToUint8Array(base64String: string) {
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
const base64 = `${base64String}${padding}`
.replace(/-/g, '+')
.replace(/_/g, '/');
const rawData = window.atob(base64);
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; ++i)
outputArray[i] = rawData.charCodeAt(i);
return outputArray;
}
export const getPushSubscription = async () => {
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.getSubscription();
return { registration, subscription };
};
export const verifyPushSubscription = async (
userId: number | undefined,
currentSettings: PublicSettingsResponse
): Promise<boolean> => {
if (!('serviceWorker' in navigator) || !userId) {
return false;
}
try {
const { subscription } = await getPushSubscription();
if (!subscription) {
return false;
}
const appServerKey = subscription.options?.applicationServerKey;
if (!(appServerKey instanceof ArrayBuffer)) {
return false;
}
const currentServerKey = new Uint8Array(appServerKey).toString();
const expectedServerKey = urlBase64ToUint8Array(
currentSettings.vapidPublic
).toString();
const endpoint = subscription.endpoint;
const { data } = await axios.get<UserPushSubscription>(
`/api/v1/user/${userId}/pushSubscription/${encodeURIComponent(endpoint)}`
);
return expectedServerKey === currentServerKey && data.endpoint === endpoint;
} catch {
return false;
}
};
export const verifyAndResubscribePushSubscription = async (
userId: number | undefined,
currentSettings: PublicSettingsResponse
): Promise<boolean> => {
const isValid = await verifyPushSubscription(userId, currentSettings);
if (isValid) {
return true;
}
if (currentSettings.enablePushRegistration) {
try {
// Unsubscribe from the backend to clear the existing push subscription (keys and endpoint)
await unsubscribeToPushNotifications(userId);
// Subscribe again to generate a fresh push subscription with updated keys and endpoint
await subscribeToPushNotifications(userId, currentSettings);
return true;
} catch (error) {
throw new Error(`[SW] Resubscribe failed: ${error.message}`);
}
}
return false;
};
export const subscribeToPushNotifications = async (
userId: number | undefined,
currentSettings: PublicSettingsResponse
) => {
if (
!('serviceWorker' in navigator) ||
!userId ||
!currentSettings.enablePushRegistration
) {
return false;
}
try {
const { registration } = await getPushSubscription();
if (!registration) {
return false;
}
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: currentSettings.vapidPublic,
});
const { endpoint, keys } = subscription.toJSON();
if (keys?.p256dh && keys?.auth) {
await axios.post('/api/v1/user/registerPushSubscription', {
endpoint,
p256dh: keys.p256dh,
auth: keys.auth,
userAgent: navigator.userAgent,
});
return true;
}
return false;
} catch (error) {
throw new Error(
`Issue subscribing to push notifications: ${error.message}`
);
}
};
export const unsubscribeToPushNotifications = async (
userId: number | undefined,
endpoint?: string
) => {
if (!('serviceWorker' in navigator) || !userId) {
return;
}
try {
const { subscription } = await getPushSubscription();
if (!subscription) {
return false;
}
const { endpoint: currentEndpoint } = subscription.toJSON();
if (!endpoint || endpoint === currentEndpoint) {
await subscription.unsubscribe();
return true;
}
} catch (error) {
throw new Error(
`Issue unsubscribing to push notifications: ${error.message}`
);
}
};