diff --git a/overseerr-api.yml b/overseerr-api.yml index 6fb01a7ec..3a5aa48f1 100644 --- a/overseerr-api.yml +++ b/overseerr-api.yml @@ -3541,6 +3541,8 @@ paths: type: string p256dh: type: string + userAgent: + type: string required: - endpoint - auth @@ -3548,6 +3550,35 @@ paths: responses: '204': description: Successfully registered push subscription + /user/{userId}/pushSubscriptions: + get: + summary: Get all web push notification settings for a user + description: | + Returns all web push notification settings for a user in a JSON object. + tags: + - users + parameters: + - in: path + name: userId + required: true + schema: + type: number + responses: + '200': + description: User web push notification settings in JSON + content: + application/json: + schema: + type: object + properties: + endpoint: + type: string + p256dh: + type: string + auth: + type: string + userAgent: + type: string /user/{userId}/pushSubscription/{key}: get: summary: Get web push notification settings for a user @@ -3580,6 +3611,8 @@ paths: type: string auth: type: string + userAgent: + type: string delete: summary: Delete user push subscription by key description: Deletes the user push subscription with the provided key. diff --git a/package.json b/package.json index 8b82e45d4..e2186ccb2 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "@supercharge/request-ip": "1.2.0", "@svgr/webpack": "6.5.1", "@tanem/react-nprogress": "5.0.30", + "@types/ua-parser-js": "^0.7.36", "ace-builds": "1.15.2", "axios": "1.3.4", "axios-rate-limit": "1.3.0", @@ -90,6 +91,7 @@ "swagger-ui-express": "4.6.2", "swr": "2.0.4", "typeorm": "0.3.12", + "ua-parser-js": "^1.0.35", "web-push": "3.5.0", "winston": "3.8.2", "winston-daily-rotate-file": "4.7.1", diff --git a/server/entity/UserPushSubscription.ts b/server/entity/UserPushSubscription.ts index 6389ea0b8..f05dd0f2b 100644 --- a/server/entity/UserPushSubscription.ts +++ b/server/entity/UserPushSubscription.ts @@ -1,4 +1,10 @@ -import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from 'typeorm'; +import { + Column, + CreateDateColumn, + Entity, + ManyToOne, + PrimaryGeneratedColumn, +} from 'typeorm'; import { User } from './User'; @Entity() @@ -18,9 +24,15 @@ export class UserPushSubscription { @Column() public p256dh: string; - @Column({ unique: true }) + @Column() public auth: string; + @Column({ nullable: true }) + public userAgent: string; + + @CreateDateColumn({ nullable: true }) + public createdAt: Date; + constructor(init?: Partial) { Object.assign(this, init); } diff --git a/server/routes/user/index.ts b/server/routes/user/index.ts index ba68b3d18..afca9bd78 100644 --- a/server/routes/user/index.ts +++ b/server/routes/user/index.ts @@ -145,13 +145,15 @@ router.post< endpoint: string; p256dh: string; auth: string; + userAgent: string; } >('/registerPushSubscription', async (req, res, next) => { try { const userPushSubRepository = getRepository(UserPushSubscription); const existingSubs = await userPushSubRepository.find({ - where: { auth: req.body.auth }, + relations: { user: true }, + where: { auth: req.body.auth, user: { id: req.user?.id } }, }); if (existingSubs.length > 0) { @@ -166,6 +168,7 @@ router.post< auth: req.body.auth, endpoint: req.body.endpoint, p256dh: req.body.p256dh, + userAgent: req.body.userAgent, user: req.user, }); @@ -180,6 +183,24 @@ router.post< } }); +router.get<{ userId: number }>( + '/:userId/pushSubscriptions', + async (req, res, next) => { + try { + const userPushSubRepository = getRepository(UserPushSubscription); + + const userPushSubs = await userPushSubRepository.find({ + relations: { user: true }, + where: { user: { id: req.params.userId } }, + }); + + return res.status(200).json(userPushSubs); + } catch (e) { + next({ status: 404, message: 'User subscriptions not found.' }); + } + } +); + router.get<{ userId: number; key: string }>( '/:userId/pushSubscription/:key', async (req, res, next) => { diff --git a/src/components/UserProfile/UserSettings/UserNotificationSettings/UserNotificationsWebPush.tsx b/src/components/UserProfile/UserSettings/UserNotificationSettings/UserNotificationsWebPush.tsx index aae9dd29c..c2d373545 100644 --- a/src/components/UserProfile/UserSettings/UserNotificationSettings/UserNotificationsWebPush.tsx +++ b/src/components/UserProfile/UserSettings/UserNotificationSettings/UserNotificationsWebPush.tsx @@ -1,5 +1,7 @@ +import Alert from '@app/components/Common/Alert'; import Button from '@app/components/Common/Button'; import LoadingSpinner from '@app/components/Common/LoadingSpinner'; +import Table from '@app/components/Common/Table'; import NotificationTypeSelector, { ALL_NOTIFICATIONS, } from '@app/components/NotificationTypeSelector'; @@ -10,6 +12,9 @@ import { ArrowDownOnSquareIcon } from '@heroicons/react/24/outline'; import { CloudArrowDownIcon, CloudArrowUpIcon, + ComputerDesktopIcon, + DevicePhoneMobileIcon, + TrashIcon, } from '@heroicons/react/24/solid'; import type { UserPushSubscription } from '@server/entity/UserPushSubscription'; import type { UserSettingsNotificationsResponse } from '@server/interfaces/api/userSettingsInterfaces'; @@ -20,12 +25,25 @@ import { useEffect, useState } from 'react'; import { defineMessages, useIntl } from 'react-intl'; import { useToasts } from 'react-toast-notifications'; import useSWR, { mutate } from 'swr'; +import { UAParser } from 'ua-parser-js'; const messages = defineMessages({ webpushsettingssaved: 'Web push notification settings saved successfully!', webpushsettingsfailed: 'Web push notification settings failed to save.', enablewebpush: 'Enable web push', disablewebpush: 'Disable web push', + managedevices: 'Manage devices', + type: 'type', + created: 'Created', + device: 'Device', + subscriptiondeleted: 'Subscription deleted.', + subscriptiondeleteerror: + 'Something went wrong while deleting the user subscription.', + nodevicestoshow: 'You have no web push subscriptions to show.', + webpushhasbeenenabled: 'Web push has been enabled.', + webpushhasbeendisabled: 'Web push has been disabled.', + enablingwebpusherror: 'Something went wrong while enable web push.', + disablingwebpusherror: 'Something went wrong while disabling web push.', }); const UserWebPushSettings = () => { @@ -42,6 +60,15 @@ const UserWebPushSettings = () => { } = useSWR( user ? `/api/v1/user/${user?.id}/settings/notifications` : null ); + const { data: dataDevices, mutate: revalidateDevices } = useSWR< + { + endpoint: string; + p256dh: string; + auth: string; + userAgent: string; + createdAt: Date; + }[] + >(`/api/v1/user/${user?.id}/pushSubscriptions`, { revalidateOnMount: true }); // Subscribes to the push manager // Will only add to the database if subscribing for the first time @@ -55,7 +82,6 @@ const UserWebPushSettings = () => { userVisibleOnly: true, applicationServerKey: currentSettings.vapidPublic, }); - const parsedSub = JSON.parse(JSON.stringify(sub)); if (parsedSub.keys.p256dh && parsedSub.keys.auth) { @@ -63,44 +89,74 @@ const UserWebPushSettings = () => { 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 (error) { - // eslint-disable-next-line no-console - console.log( - '[SW] Failure subscribing to push manager, error:', - error - ); + .catch(function () { + addToast(intl.formatMessage(messages.enablingwebpusherror), { + autoDismiss: true, + appearance: 'error', + }); + }) + .finally(function () { + revalidateDevices(); }); } }; - // Unsubscribes to the push manager - const disablePushNotifications = () => { + // Unsubscribes from the push manager + // Deletes/disables corresponding push subscription from database + const disablePushNotifications = async (p256dh?: string) => { if ('serviceWorker' in navigator && user?.id) { navigator.serviceWorker.getRegistration('/sw.js').then((registration) => { registration?.pushManager .getSubscription() .then(async (subscription) => { - subscription - ?.unsubscribe() - .then(async () => { - const parsedSub = JSON.parse(JSON.stringify(subscription)); - await axios.delete( - `/api/v1/user/${user.id}/pushSubscription/${parsedSub.keys.p256dh}` - ); - setWebPushEnabled(false); - }) - .catch(function (error) { - // eslint-disable-next-line no-console - console.log( - '[SW] Failure unsubscribing to push manager, error:', - error - ); - }); + const parsedSub = JSON.parse(JSON.stringify(subscription)); + + await axios.delete( + `/api/v1/user/${user?.id}/pushSubscription/${ + p256dh ? p256dh : parsedSub.keys.p256dh + }` + ); + if (subscription && (p256dh === parsedSub.keys.p256dh || !p256dh)) { + subscription.unsubscribe(); + setWebPushEnabled(false); + } + addToast( + intl.formatMessage( + p256dh + ? messages.subscriptiondeleted + : messages.webpushhasbeendisabled + ), + { + autoDismiss: true, + appearance: 'success', + } + ); + }) + .catch(function () { + addToast( + intl.formatMessage( + p256dh + ? messages.subscriptiondeleteerror + : messages.disablingwebpusherror + ), + { + autoDismiss: true, + appearance: 'error', + } + ); + }) + .finally(function () { + revalidateDevices(); }); }); } @@ -127,10 +183,13 @@ const UserWebPushSettings = () => { 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:', @@ -145,108 +204,180 @@ const UserWebPushSettings = () => { } return ( - { - try { - await axios.post(`/api/v1/user/${user?.id}/settings/notifications`, { - pgpKey: data?.pgpKey, - discordId: data?.discordId, - pushbulletAccessToken: data?.pushbulletAccessToken, - pushoverApplicationToken: data?.pushoverApplicationToken, - pushoverUserKey: data?.pushoverUserKey, - telegramChatId: data?.telegramChatId, - telegramSendSilently: data?.telegramSendSilently, - notificationTypes: { - webpush: values.types, - }, - }); - mutate('/api/v1/settings/public'); - addToast(intl.formatMessage(messages.webpushsettingssaved), { - appearance: 'success', - autoDismiss: true, - }); - } catch (e) { - addToast(intl.formatMessage(messages.webpushsettingsfailed), { - appearance: 'error', - autoDismiss: true, - }); - } finally { - revalidate(); - } - }} - > - {({ - errors, - touched, - isSubmitting, - isValid, - values, - setFieldValue, - setFieldTouched, - }) => { - return ( -
- { - setFieldValue('types', newTypes); - setFieldTouched('types'); - }} - error={ - errors.types && touched.types - ? (errors.types as string) - : undefined + <> + { + try { + await axios.post( + `/api/v1/user/${user?.id}/settings/notifications`, + { + pgpKey: data?.pgpKey, + discordId: data?.discordId, + pushbulletAccessToken: data?.pushbulletAccessToken, + pushoverApplicationToken: data?.pushoverApplicationToken, + pushoverUserKey: data?.pushoverUserKey, + telegramChatId: data?.telegramChatId, + telegramSendSilently: data?.telegramSendSilently, + notificationTypes: { + webpush: values.types, + }, } - /> -
-
- - - - - - + ); + mutate('/api/v1/settings/public'); + addToast(intl.formatMessage(messages.webpushsettingssaved), { + appearance: 'success', + autoDismiss: true, + }); + } catch (e) { + addToast(intl.formatMessage(messages.webpushsettingsfailed), { + appearance: 'error', + autoDismiss: true, + }); + } finally { + revalidate(); + } + }} + > + {({ + errors, + touched, + isSubmitting, + isValid, + values, + setFieldValue, + setFieldTouched, + }) => { + return ( + + { + setFieldValue('types', newTypes); + setFieldTouched('types'); + }} + error={ + errors.types && touched.types + ? (errors.types as string) + : undefined + } + /> +
+
+ + + + + + +
-
- - ); - }} - + + ); + }} + +
+

+ {intl.formatMessage(messages.managedevices)} +

+ {dataDevices?.length ? ( + + + + {intl.formatMessage(messages.type)} + {intl.formatMessage(messages.device)} + {intl.formatMessage(messages.created)} + + + + + {dataDevices?.map((device) => ( + + +
+
+ {UAParser(device.userAgent).device.type === 'mobile' ? ( + + ) : ( + + )} +
+
+
+ + {device.userAgent + ? UAParser(device.userAgent).device.model + : 'Unknown'} + + + {device.createdAt + ? intl.formatDate(device.createdAt, { + year: 'numeric', + month: 'long', + day: 'numeric', + }) + : 'Unknown'} + + + + + + ))} + +
+ ) : ( + <> +
+ + + )} +
+ ); }; diff --git a/yarn.lock b/yarn.lock index 886aee53e..5555ad10e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4040,6 +4040,11 @@ "@types/express" "*" "@types/serve-static" "*" +"@types/ua-parser-js@^0.7.36": + version "0.7.36" + resolved "https://registry.yarnpkg.com/@types/ua-parser-js/-/ua-parser-js-0.7.36.tgz#9bd0b47f26b5a3151be21ba4ce9f5fa457c5f190" + integrity sha512-N1rW+njavs70y2cApeIw1vLMYXRwfBy+7trgavGuuTfOd7j1Yh7QTRc/yqsPl6ncokt72ZXuxEU0PiCp9bSwNQ== + "@types/unist@*", "@types/unist@^2.0.0": version "2.0.6" resolved "https://registry.yarnpkg.com/@types/unist/-/unist-2.0.6.tgz#250a7b16c3b91f672a24552ec64678eeb1d3a08d" @@ -13332,6 +13337,11 @@ typescript@^4.0, typescript@^4.6.4, typescript@^4.7: resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.9.4.tgz#a2a3d2756c079abda241d75f149df9d561091e78" integrity sha512-Uz+dTXYzxXXbsFpM86Wh3dKCxrQqUcVMxwU54orwlJjOpO3ao8L7j5lH+dWfTwgCwIuM9GQ2kvVotzYJMXTBZg== +ua-parser-js@^1.0.35: + version "1.0.35" + resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-1.0.35.tgz#c4ef44343bc3db0a3cbefdf21822f1b1fc1ab011" + integrity sha512-fKnGuqmTBnIE+/KXSzCn4db8RTigUzw1AN0DmdU6hJovUTbYJKyqj+8Mt1c4VfRDnOVJnENmfYkIPZ946UrSAA== + uc.micro@^1.0.1: version "1.0.6" resolved "https://registry.yarnpkg.com/uc.micro/-/uc.micro-1.0.6.tgz#9c411a802a409a91fc6cf74081baba34b24499ac"