chore: merge upstream (#2024)
This commit is contained in:
@@ -8,7 +8,7 @@ import { getHostname } from '@server/utils/getHostname';
|
|||||||
const migrateApiTokens = async (settings: any): Promise<AllSettings> => {
|
const migrateApiTokens = async (settings: any): Promise<AllSettings> => {
|
||||||
const mediaServerType = settings.main.mediaServerType;
|
const mediaServerType = settings.main.mediaServerType;
|
||||||
if (
|
if (
|
||||||
!settings.jellyfin.apiKey &&
|
!settings.jellyfin?.apiKey &&
|
||||||
(mediaServerType === MediaServerType.JELLYFIN ||
|
(mediaServerType === MediaServerType.JELLYFIN ||
|
||||||
mediaServerType === MediaServerType.EMBY)
|
mediaServerType === MediaServerType.EMBY)
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
/* eslint-disable no-console */
|
/* eslint-disable no-console */
|
||||||
|
|
||||||
|
import useSettings from '@app/hooks/useSettings';
|
||||||
import { useUser } from '@app/hooks/useUser';
|
import { useUser } from '@app/hooks/useUser';
|
||||||
|
import { verifyAndResubscribePushSubscription } from '@app/utils/pushSubscriptionHelpers';
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
const ServiceWorkerSetup = () => {
|
const ServiceWorkerSetup = () => {
|
||||||
const { user } = useUser();
|
const { user } = useUser();
|
||||||
|
const { currentSettings } = useSettings();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if ('serviceWorker' in navigator && user?.id) {
|
if ('serviceWorker' in navigator && user?.id) {
|
||||||
navigator.serviceWorker
|
navigator.serviceWorker
|
||||||
@@ -14,12 +18,53 @@ const ServiceWorkerSetup = () => {
|
|||||||
'[SW] Registration successful, scope is:',
|
'[SW] Registration successful, scope is:',
|
||||||
registration.scope
|
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) {
|
.catch(function (error) {
|
||||||
console.log('[SW] Service worker registration failed, error:', error);
|
console.log('[SW] Service worker registration failed, error:', error);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [user]);
|
}, [currentSettings, user]);
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +1,18 @@
|
|||||||
|
import Button from '@app/components/Common/Button';
|
||||||
import ConfirmButton from '@app/components/Common/ConfirmButton';
|
import ConfirmButton from '@app/components/Common/ConfirmButton';
|
||||||
import globalMessages from '@app/i18n/globalMessages';
|
import globalMessages from '@app/i18n/globalMessages';
|
||||||
import defineMessages from '@app/utils/defineMessages';
|
import defineMessages from '@app/utils/defineMessages';
|
||||||
import {
|
import {
|
||||||
ComputerDesktopIcon,
|
ComputerDesktopIcon,
|
||||||
DevicePhoneMobileIcon,
|
DevicePhoneMobileIcon,
|
||||||
|
LockClosedIcon,
|
||||||
TrashIcon,
|
TrashIcon,
|
||||||
} from '@heroicons/react/24/solid';
|
} from '@heroicons/react/24/solid';
|
||||||
import { useIntl } from 'react-intl';
|
import { useIntl } from 'react-intl';
|
||||||
import { UAParser } from 'ua-parser-js';
|
import { UAParser } from 'ua-parser-js';
|
||||||
|
|
||||||
interface DeviceItemProps {
|
interface DeviceItemProps {
|
||||||
disablePushNotifications: (p256dh: string) => void;
|
deletePushSubscriptionFromBackend: (endpoint: string) => void;
|
||||||
device: {
|
device: {
|
||||||
endpoint: string;
|
endpoint: string;
|
||||||
p256dh: string;
|
p256dh: string;
|
||||||
@@ -18,6 +20,7 @@ interface DeviceItemProps {
|
|||||||
userAgent: string;
|
userAgent: string;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
};
|
};
|
||||||
|
subEndpoint: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const messages = defineMessages(
|
const messages = defineMessages(
|
||||||
@@ -28,10 +31,15 @@ const messages = defineMessages(
|
|||||||
engine: 'Engine',
|
engine: 'Engine',
|
||||||
deletesubscription: 'Delete Subscription',
|
deletesubscription: 'Delete Subscription',
|
||||||
unknown: 'Unknown',
|
unknown: 'Unknown',
|
||||||
|
activesubscription: 'Active Subscription',
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
const DeviceItem = ({ disablePushNotifications, device }: DeviceItemProps) => {
|
const DeviceItem = ({
|
||||||
|
deletePushSubscriptionFromBackend,
|
||||||
|
device,
|
||||||
|
subEndpoint,
|
||||||
|
}: DeviceItemProps) => {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const parsedUserAgent = UAParser(device.userAgent);
|
const parsedUserAgent = UAParser(device.userAgent);
|
||||||
|
|
||||||
@@ -91,14 +99,21 @@ const DeviceItem = ({ disablePushNotifications, device }: DeviceItemProps) => {
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<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">
|
||||||
|
{subEndpoint === device.endpoint ? (
|
||||||
|
<Button buttonType="primary" className="w-full" disabled>
|
||||||
|
<LockClosedIcon />{' '}
|
||||||
|
<span>{intl.formatMessage(messages.activesubscription)}</span>
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
<ConfirmButton
|
<ConfirmButton
|
||||||
onClick={() => disablePushNotifications(device.endpoint)}
|
onClick={() => deletePushSubscriptionFromBackend(device.endpoint)}
|
||||||
confirmText={intl.formatMessage(globalMessages.areyousure)}
|
confirmText={intl.formatMessage(globalMessages.areyousure)}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
>
|
>
|
||||||
<TrashIcon />
|
<TrashIcon />
|
||||||
<span>{intl.formatMessage(messages.deletesubscription)}</span>
|
<span>{intl.formatMessage(messages.deletesubscription)}</span>
|
||||||
</ConfirmButton>
|
</ConfirmButton>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -9,17 +9,22 @@ import useSettings from '@app/hooks/useSettings';
|
|||||||
import { useUser } from '@app/hooks/useUser';
|
import { useUser } from '@app/hooks/useUser';
|
||||||
import globalMessages from '@app/i18n/globalMessages';
|
import globalMessages from '@app/i18n/globalMessages';
|
||||||
import defineMessages from '@app/utils/defineMessages';
|
import defineMessages from '@app/utils/defineMessages';
|
||||||
|
import {
|
||||||
|
getPushSubscription,
|
||||||
|
subscribeToPushNotifications,
|
||||||
|
unsubscribeToPushNotifications,
|
||||||
|
verifyPushSubscription,
|
||||||
|
} from '@app/utils/pushSubscriptionHelpers';
|
||||||
import { ArrowDownOnSquareIcon } from '@heroicons/react/24/outline';
|
import { ArrowDownOnSquareIcon } from '@heroicons/react/24/outline';
|
||||||
import {
|
import {
|
||||||
CloudArrowDownIcon,
|
CloudArrowDownIcon,
|
||||||
CloudArrowUpIcon,
|
CloudArrowUpIcon,
|
||||||
} from '@heroicons/react/24/solid';
|
} from '@heroicons/react/24/solid';
|
||||||
import type { UserPushSubscription } from '@server/entity/UserPushSubscription';
|
|
||||||
import type { UserSettingsNotificationsResponse } from '@server/interfaces/api/userSettingsInterfaces';
|
import type { UserSettingsNotificationsResponse } from '@server/interfaces/api/userSettingsInterfaces';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { Form, Formik } from 'formik';
|
import { Form, Formik } from 'formik';
|
||||||
import { useRouter } from 'next/router';
|
import { useRouter } from 'next/router';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { useIntl } from 'react-intl';
|
import { useIntl } from 'react-intl';
|
||||||
import { useToasts } from 'react-toast-notifications';
|
import { useToasts } from 'react-toast-notifications';
|
||||||
import useSWR, { mutate } from 'swr';
|
import useSWR, { mutate } from 'swr';
|
||||||
@@ -53,6 +58,7 @@ const UserWebPushSettings = () => {
|
|||||||
const { user } = useUser({ id: Number(router.query.userId) });
|
const { user } = useUser({ id: Number(router.query.userId) });
|
||||||
const { currentSettings } = useSettings();
|
const { currentSettings } = useSettings();
|
||||||
const [webPushEnabled, setWebPushEnabled] = useState(false);
|
const [webPushEnabled, setWebPushEnabled] = useState(false);
|
||||||
|
const [subEndpoint, setSubEndpoint] = useState<string | null>(null);
|
||||||
const {
|
const {
|
||||||
data,
|
data,
|
||||||
error,
|
error,
|
||||||
@@ -72,141 +78,122 @@ const UserWebPushSettings = () => {
|
|||||||
|
|
||||||
// Subscribes to the push manager
|
// Subscribes to the push manager
|
||||||
// Will only add to the database if subscribing for the first time
|
// Will only add to the database if subscribing for the first time
|
||||||
const enablePushNotifications = () => {
|
const enablePushNotifications = async () => {
|
||||||
if ('serviceWorker' in navigator && user?.id) {
|
try {
|
||||||
navigator.serviceWorker
|
const isSubscribed = await subscribeToPushNotifications(
|
||||||
.getRegistration('/sw.js')
|
user?.id,
|
||||||
.then(async (registration) => {
|
currentSettings
|
||||||
if (currentSettings.enablePushRegistration) {
|
);
|
||||||
const sub = await registration?.pushManager.subscribe({
|
|
||||||
userVisibleOnly: true,
|
|
||||||
applicationServerKey: currentSettings.vapidPublic,
|
|
||||||
});
|
|
||||||
const parsedSub = JSON.parse(JSON.stringify(sub));
|
|
||||||
|
|
||||||
if (parsedSub.keys.p256dh && parsedSub.keys.auth) {
|
if (isSubscribed) {
|
||||||
await axios.post('/api/v1/user/registerPushSubscription', {
|
localStorage.setItem('pushNotificationsEnabled', 'true');
|
||||||
endpoint: parsedSub.endpoint,
|
|
||||||
p256dh: parsedSub.keys.p256dh,
|
|
||||||
auth: parsedSub.keys.auth,
|
|
||||||
userAgent: navigator.userAgent,
|
|
||||||
});
|
|
||||||
setWebPushEnabled(true);
|
setWebPushEnabled(true);
|
||||||
addToast(intl.formatMessage(messages.webpushhasbeenenabled), {
|
addToast(intl.formatMessage(messages.webpushhasbeenenabled), {
|
||||||
appearance: 'success',
|
appearance: 'success',
|
||||||
autoDismiss: true,
|
autoDismiss: true,
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
throw new Error('Subscription failed');
|
||||||
}
|
}
|
||||||
}
|
} catch (error) {
|
||||||
})
|
|
||||||
.catch(function () {
|
|
||||||
addToast(intl.formatMessage(messages.enablingwebpusherror), {
|
addToast(intl.formatMessage(messages.enablingwebpusherror), {
|
||||||
autoDismiss: true,
|
|
||||||
appearance: 'error',
|
appearance: 'error',
|
||||||
|
autoDismiss: true,
|
||||||
});
|
});
|
||||||
})
|
} finally {
|
||||||
.finally(function () {
|
|
||||||
revalidateDevices();
|
revalidateDevices();
|
||||||
});
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Unsubscribes from the push manager
|
// Unsubscribes from the push manager
|
||||||
// Deletes/disables corresponding push subscription from database
|
// Deletes/disables corresponding push subscription from database
|
||||||
const disablePushNotifications = async (endpoint?: string) => {
|
const disablePushNotifications = async (endpoint?: string) => {
|
||||||
if ('serviceWorker' in navigator && user?.id) {
|
try {
|
||||||
navigator.serviceWorker.getRegistration('/sw.js').then((registration) => {
|
await unsubscribeToPushNotifications(user?.id, endpoint);
|
||||||
registration?.pushManager
|
|
||||||
.getSubscription()
|
|
||||||
.then(async (subscription) => {
|
|
||||||
const parsedSub = JSON.parse(JSON.stringify(subscription));
|
|
||||||
|
|
||||||
await axios.delete(
|
localStorage.setItem('pushNotificationsEnabled', 'false');
|
||||||
`/api/v1/user/${user.id}/pushSubscription/${encodeURIComponent(
|
|
||||||
endpoint ?? parsedSub.endpoint
|
|
||||||
)}`
|
|
||||||
);
|
|
||||||
|
|
||||||
if (
|
|
||||||
subscription &&
|
|
||||||
(endpoint === parsedSub.endpoint || !endpoint)
|
|
||||||
) {
|
|
||||||
subscription.unsubscribe();
|
|
||||||
setWebPushEnabled(false);
|
setWebPushEnabled(false);
|
||||||
}
|
addToast(intl.formatMessage(messages.webpushhasbeendisabled), {
|
||||||
addToast(
|
|
||||||
intl.formatMessage(
|
|
||||||
endpoint
|
|
||||||
? messages.subscriptiondeleted
|
|
||||||
: messages.webpushhasbeendisabled
|
|
||||||
),
|
|
||||||
{
|
|
||||||
autoDismiss: true,
|
autoDismiss: true,
|
||||||
appearance: 'success',
|
appearance: 'success',
|
||||||
}
|
});
|
||||||
);
|
} catch (error) {
|
||||||
})
|
addToast(intl.formatMessage(messages.disablingwebpusherror), {
|
||||||
.catch(function () {
|
|
||||||
addToast(
|
|
||||||
intl.formatMessage(
|
|
||||||
endpoint
|
|
||||||
? messages.subscriptiondeleteerror
|
|
||||||
: messages.disablingwebpusherror
|
|
||||||
),
|
|
||||||
{
|
|
||||||
autoDismiss: true,
|
autoDismiss: true,
|
||||||
appearance: 'error',
|
appearance: 'error',
|
||||||
}
|
});
|
||||||
);
|
} finally {
|
||||||
})
|
|
||||||
.finally(function () {
|
|
||||||
revalidateDevices();
|
revalidateDevices();
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Checks our current subscription on page load
|
const deletePushSubscriptionFromBackend = async (endpoint: string) => {
|
||||||
// Will set the web push state to true if subscribed
|
try {
|
||||||
useEffect(() => {
|
await axios.delete(
|
||||||
if ('serviceWorker' in navigator && user?.id) {
|
`/api/v1/user/${user?.id}/pushSubscription/${encodeURIComponent(
|
||||||
navigator.serviceWorker
|
endpoint
|
||||||
.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
|
|
||||||
)}`
|
)}`
|
||||||
);
|
);
|
||||||
|
|
||||||
if (currentUserPushSub.data.endpoint !== parsedKey.endpoint) {
|
addToast(intl.formatMessage(messages.subscriptiondeleted), {
|
||||||
return;
|
autoDismiss: true,
|
||||||
|
appearance: 'success',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
addToast(intl.formatMessage(messages.subscriptiondeleteerror), {
|
||||||
|
autoDismiss: true,
|
||||||
|
appearance: 'error',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
revalidateDevices();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
setWebPushEnabled(true);
|
return [...dataDevices].sort((a, b) => {
|
||||||
} else {
|
if (a.endpoint === subEndpoint) {
|
||||||
setWebPushEnabled(false);
|
return -1;
|
||||||
}
|
}
|
||||||
});
|
if (b.endpoint === subEndpoint) {
|
||||||
})
|
return 1;
|
||||||
.catch(function (error) {
|
|
||||||
setWebPushEnabled(false);
|
|
||||||
// eslint-disable-next-line no-console
|
|
||||||
console.log(
|
|
||||||
'[SW] Failure retrieving push manager subscription, error:',
|
|
||||||
error
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}, [user?.id]);
|
|
||||||
|
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) {
|
if (!data && !error) {
|
||||||
return <LoadingSpinner />;
|
return <LoadingSpinner />;
|
||||||
@@ -324,19 +311,15 @@ const UserWebPushSettings = () => {
|
|||||||
{intl.formatMessage(messages.managedevices)}
|
{intl.formatMessage(messages.managedevices)}
|
||||||
</h3>
|
</h3>
|
||||||
<div className="section">
|
<div className="section">
|
||||||
{dataDevices?.length ? (
|
{sortedDevices?.length ? (
|
||||||
dataDevices
|
sortedDevices.map((device) => (
|
||||||
?.sort((a, b) => {
|
<div className="py-2" key={`device-list-${device.endpoint}`}>
|
||||||
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
|
<DeviceItem
|
||||||
key={index}
|
deletePushSubscriptionFromBackend={
|
||||||
disablePushNotifications={disablePushNotifications}
|
deletePushSubscriptionFromBackend
|
||||||
|
}
|
||||||
device={device}
|
device={device}
|
||||||
|
subEndpoint={subEndpoint}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
|
|||||||
@@ -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.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.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.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.browser": "Browser",
|
||||||
"components.UserProfile.UserSettings.UserNotificationSettings.UserNotificationsWebPush.created": "Created",
|
"components.UserProfile.UserSettings.UserNotificationSettings.UserNotificationsWebPush.created": "Created",
|
||||||
"components.UserProfile.UserSettings.UserNotificationSettings.UserNotificationsWebPush.deletesubscription": "Delete Subscription",
|
"components.UserProfile.UserSettings.UserNotificationSettings.UserNotificationsWebPush.deletesubscription": "Delete Subscription",
|
||||||
|
|||||||
162
src/utils/pushSubscriptionHelpers.ts
Normal file
162
src/utils/pushSubscriptionHelpers.ts
Normal 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}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user