diff --git a/.all-contributorsrc b/.all-contributorsrc index 4cbc6e2c1..b68f27caa 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -249,7 +249,8 @@ "avatar_url": "https://avatars.githubusercontent.com/u/854646?v=4", "profile": "http://www.piribisoft.com", "contributions": [ - "doc" + "doc", + "code" ] }, { @@ -711,6 +712,105 @@ "contributions": [ "code" ] + }, + { + "login": "j0srisk", + "name": "Joseph Risk", + "avatar_url": "https://avatars.githubusercontent.com/u/18372584?v=4", + "profile": "http://josephrisk.com", + "contributions": [ + "code" + ] + }, + { + "login": "Loetwiek", + "name": "Loetwiek", + "avatar_url": "https://avatars.githubusercontent.com/u/79059734?v=4", + "profile": "https://github.com/Loetwiek", + "contributions": [ + "code" + ] + }, + { + "login": "Fuochi", + "name": "Fuochi", + "avatar_url": "https://avatars.githubusercontent.com/u/4720478?v=4", + "profile": "https://github.com/Fuochi", + "contributions": [ + "doc" + ] + }, + { + "login": "demrich", + "name": "David Emrich", + "avatar_url": "https://avatars.githubusercontent.com/u/30092389?v=4", + "profile": "https://github.com/demrich", + "contributions": [ + "code" + ] + }, + { + "login": "maxnatamo", + "name": "Max T. Kristiansen", + "avatar_url": "https://avatars.githubusercontent.com/u/5898152?v=4", + "profile": "https://maxtrier.dk", + "contributions": [ + "code" + ] + }, + { + "login": "DamsDev1", + "name": "Damien Fajole", + "avatar_url": "https://avatars.githubusercontent.com/u/60252259?v=4", + "profile": "https://damsdev.me", + "contributions": [ + "code" + ] + }, + { + "login": "AhmedNSidd", + "name": "Ahmed Siddiqui", + "avatar_url": "https://avatars.githubusercontent.com/u/36286128?v=4", + "profile": "https://github.com/AhmedNSidd", + "contributions": [ + "code" + ] + }, + { + "login": "JackW6809", + "name": "JackOXI", + "avatar_url": "https://avatars.githubusercontent.com/u/53652452?v=4", + "profile": "https://github.com/JackW6809", + "contributions": [ + "code" + ] + }, + { + "login": "StancuFlorin", + "name": "Stancu Florin", + "avatar_url": "https://avatars.githubusercontent.com/u/1199404?v=4", + "profile": "http://indicus.ro", + "contributions": [ + "code" + ] + }, + { + "login": "lmiklosko", + "name": "Lukas Miklosko", + "avatar_url": "https://avatars.githubusercontent.com/u/44380311?v=4", + "profile": "https://github.com/lmiklosko", + "contributions": [ + "code" + ] + }, + { + "login": "gauthier-th", + "name": "Gauthier", + "avatar_url": "https://avatars.githubusercontent.com/u/37781713?v=4", + "profile": "https://gauthierth.fr/", + "contributions": [ + "code" + ] } ] } diff --git a/README.md b/README.md index a275de0c6..02d8839e9 100644 --- a/README.md +++ b/README.md @@ -335,6 +335,8 @@ Thanks goes to these wonderful people from Overseerr ([emoji key](https://allcon Ahmed Siddiqui
Ahmed Siddiqui

💻 JackOXI
JackOXI

💻 Stancu Florin
Stancu Florin

💻 + Lukas Miklosko
Lukas Miklosko

💻 + Gauthier
Gauthier

💻 diff --git a/jellyseerr-api.yml b/jellyseerr-api.yml index 00b095968..6954992d8 100644 --- a/jellyseerr-api.yml +++ b/jellyseerr-api.yml @@ -3965,6 +3965,8 @@ paths: type: string p256dh: type: string + userAgent: + type: string required: - endpoint - auth @@ -3972,6 +3974,88 @@ 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 + description: | + Returns web push notification settings for a user in a JSON object. + tags: + - users + parameters: + - in: path + name: userId + required: true + schema: + type: number + - in: path + name: key + required: true + schema: + type: string + 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 + delete: + summary: Delete user push subscription by key + description: Deletes the user push subscription with the provided key. + tags: + - users + parameters: + - in: path + name: userId + required: true + schema: + type: number + - in: path + name: key + required: true + schema: + type: string + responses: + '204': + description: Successfully removed user push subscription /user/{userId}: get: summary: Get user by ID diff --git a/package.json b/package.json index a0e8a0ef0..143777810 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "@svgr/webpack": "6.5.1", "@tanem/react-nprogress": "5.0.30", "@types/wink-jaro-distance": "^2.0.2", + "@types/ua-parser-js": "^0.7.36", "ace-builds": "1.15.2", "bcrypt": "5.1.0", "bowser": "2.11.0", @@ -99,6 +100,7 @@ "tailwind-merge": "^2.6.0", "typeorm": "0.3.11", "undici": "^7.3.0", + "ua-parser-js": "^1.0.35", "web-push": "3.5.0", "wink-jaro-distance": "^2.0.0", "winston": "3.8.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 61874f355..86ec16a6e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -41,6 +41,9 @@ importers: '@tanem/react-nprogress': specifier: 5.0.30 version: 5.0.30(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@types/ua-parser-js': + specifier: ^0.7.36 + version: 0.7.39 '@types/wink-jaro-distance': specifier: ^2.0.2 version: 2.0.2 @@ -206,6 +209,9 @@ importers: typeorm: specifier: 0.3.11 version: 0.3.11(pg@8.11.0)(sqlite3@5.1.7)(ts-node@10.9.1(@swc/core@1.6.5(@swc/helpers@0.5.11))(@types/node@22.10.5)(typescript@4.9.5)) + ua-parser-js: + specifier: ^1.0.35 + version: 1.0.40 undici: specifier: ^7.3.0 version: 7.3.0 @@ -3412,6 +3418,9 @@ packages: '@types/triple-beam@1.3.5': resolution: {integrity: sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==} + '@types/ua-parser-js@0.7.39': + resolution: {integrity: sha512-P/oDfpofrdtF5xw433SPALpdSchtJmY7nsJItf8h3KXqOslkbySh8zq4dSWXH2oTjRvJ5PczVEoCZPow6GicLg==} + '@types/unist@2.0.10': resolution: {integrity: sha512-IfYcSBWE3hLpBg8+X2SEa8LVkJdJEkT2Ese2aaLs3ptGdVtABxndrMaxuFlQ1qdFf9Q5rDvDpxI3WwgvKFAsQA==} @@ -9223,6 +9232,10 @@ packages: engines: {node: '>=14.17'} hasBin: true + ua-parser-js@1.0.40: + resolution: {integrity: sha512-z6PJ8Lml+v3ichVojCiB8toQJBuwR42ySM4ezjXIqXK3M0HczmKQ3LF4rhU55PfD99KEEXQG6yb7iOMyvYuHew==} + hasBin: true + uc.micro@2.1.0: resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==} @@ -13778,6 +13791,8 @@ snapshots: '@types/triple-beam@1.3.5': {} + '@types/ua-parser-js@0.7.39': {} + '@types/unist@2.0.10': {} '@types/web-push@3.3.2': @@ -20672,6 +20687,8 @@ snapshots: typescript@5.5.2: {} + ua-parser-js@1.0.40: {} + uc.micro@2.1.0: optional: true diff --git a/server/api/servarr/radarr.ts b/server/api/servarr/radarr.ts index f3bf3faaf..35d24024f 100644 --- a/server/api/servarr/radarr.ts +++ b/server/api/servarr/radarr.ts @@ -28,6 +28,7 @@ export interface RadarrMovie { qualityProfileId: number; added: string; hasFile: boolean; + tags: number[]; } class RadarrAPI extends ServarrBase<{ movieId: number }> { @@ -104,7 +105,7 @@ class RadarrAPI extends ServarrBase<{ movieId: number }> { minimumAvailability: options.minimumAvailability, tmdbId: options.tmdbId, year: options.year, - tags: options.tags, + tags: Array.from(new Set([...movie.tags, ...options.tags])), rootFolderPath: options.rootFolderPath, monitored: options.monitored, addOptions: { diff --git a/server/api/servarr/sonarr.ts b/server/api/servarr/sonarr.ts index 5590c9acb..0a9c27322 100644 --- a/server/api/servarr/sonarr.ts +++ b/server/api/servarr/sonarr.ts @@ -184,7 +184,9 @@ class SonarrAPI extends ServarrBase<{ // If the series already exists, we will simply just update it if (series.id) { series.monitored = options.monitored ?? series.monitored; - series.tags = options.tags ?? series.tags; + series.tags = options.tags + ? Array.from(new Set([...series.tags, ...options.tags])) + : series.tags; series.seasons = this.buildSeasonList(options.seasons, series.seasons); const newSeriesData = await this.put( 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/migration/1740717744278-UpdateWebPush.ts b/server/migration/1740717744278-UpdateWebPush.ts new file mode 100644 index 000000000..a6dcd0021 --- /dev/null +++ b/server/migration/1740717744278-UpdateWebPush.ts @@ -0,0 +1,31 @@ +import type { MigrationInterface, QueryRunner } from 'typeorm'; + +export class UpdateWebPush1740717744278 implements MigrationInterface { + name = 'UpdateWebPush1740717744278'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "temporary_user_push_subscription" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "endpoint" varchar NOT NULL, "p256dh" varchar NOT NULL, "auth" varchar NOT NULL, "userId" integer, "userAgent" varchar DEFAULT NULL, "createdAt" datetime DEFAULT (datetime('now')), CONSTRAINT "UQ_f90ab5a4ed54905a4bb51a7148b" UNIQUE ("auth"), CONSTRAINT "FK_03f7958328e311761b0de675fbe" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` + ); + await queryRunner.query( + `INSERT INTO "temporary_user_push_subscription"("id", "endpoint", "p256dh", "auth", "userId") SELECT "id", "endpoint", "p256dh", "auth", "userId" FROM "user_push_subscription"` + ); + await queryRunner.query(`DROP TABLE "user_push_subscription"`); + await queryRunner.query( + `ALTER TABLE "temporary_user_push_subscription" RENAME TO "user_push_subscription"` + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "user_push_subscription" RENAME TO "temporary_user_push_subscription"` + ); + await queryRunner.query( + `CREATE TABLE "user_push_subscription" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "endpoint" varchar NOT NULL, "p256dh" varchar NOT NULL, "auth" varchar NOT NULL, "userId" integer, "userAgent" varchar, "createdAt" datetime DEFAULT (datetime('now')), CONSTRAINT "UQ_f90ab5a4ed54905a4bb51a7148b" UNIQUE ("auth"), CONSTRAINT "FK_03f7958328e311761b0de675fbe" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` + ); + await queryRunner.query( + `INSERT INTO "user_push_subscription"("id", "endpoint", "p256dh", "auth", "userId") SELECT "id", "endpoint", "p256dh", "auth", "userId" FROM "temporary_user_push_subscription"` + ); + await queryRunner.query(`DROP TABLE "temporary_user_push_subscription"`); + } +} diff --git a/server/routes/user/index.ts b/server/routes/user/index.ts index 0c79e4f3e..028b26e62 100644 --- a/server/routes/user/index.ts +++ b/server/routes/user/index.ts @@ -184,13 +184,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) { @@ -205,6 +207,7 @@ router.post< auth: req.body.auth, endpoint: req.body.endpoint, p256dh: req.body.p256dh, + userAgent: req.body.userAgent, user: req.user, }); @@ -219,6 +222,79 @@ 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) => { + try { + const userPushSubRepository = getRepository(UserPushSubscription); + + const userPushSub = await userPushSubRepository.findOneOrFail({ + relations: { + user: true, + }, + where: { + user: { id: req.params.userId }, + p256dh: req.params.key, + }, + }); + + return res.status(200).json(userPushSub); + } catch (e) { + next({ status: 404, message: 'User subscription not found.' }); + } + } +); + +router.delete<{ userId: number; key: string }>( + '/:userId/pushSubscription/:key', + async (req, res, next) => { + try { + const userPushSubRepository = getRepository(UserPushSubscription); + + const userPushSub = await userPushSubRepository.findOneOrFail({ + relations: { + user: true, + }, + where: { + user: { id: req.params.userId }, + p256dh: req.params.key, + }, + }); + + await userPushSubRepository.remove(userPushSub); + return res.status(204).send(); + } catch (e) { + logger.error('Something went wrong deleting the user push subcription', { + label: 'API', + key: req.params.key, + errorMessage: e.message, + }); + return next({ + status: 500, + message: 'User push subcription not found', + }); + } + } +); + router.get<{ id: string }>('/:id', async (req, res, next) => { try { const userRepository = getRepository(User); diff --git a/src/components/Layout/MobileMenu/index.tsx b/src/components/Layout/MobileMenu/index.tsx index 52e84d3de..09cec4a01 100644 --- a/src/components/Layout/MobileMenu/index.tsx +++ b/src/components/Layout/MobileMenu/index.tsx @@ -255,7 +255,9 @@ const MobileMenu = ({ router.pathname.match(link.activeRegExp) ? 'border-indigo-600 from-indigo-700 to-purple-700' : 'border-indigo-500 from-indigo-600 to-purple-600' - } flex h-4 w-4 items-center justify-center !px-[9px] !py-[9px] text-[9px]`} + } flex ${ + pendingRequestsCount > 99 ? 'w-6' : 'w-4' + } h-4 items-center justify-center !px-[5px] !py-[7px] text-[8px]`} > {pendingRequestsCount > 99 ? '99+' diff --git a/src/components/ServiceWorkerSetup/index.tsx b/src/components/ServiceWorkerSetup/index.tsx index f9b42cd39..2e0313f4d 100644 --- a/src/components/ServiceWorkerSetup/index.tsx +++ b/src/components/ServiceWorkerSetup/index.tsx @@ -1,10 +1,9 @@ /* eslint-disable no-console */ -import useSettings from '@app/hooks/useSettings'; + import { useUser } from '@app/hooks/useUser'; import { useEffect } from 'react'; const ServiceWorkerSetup = () => { - const { currentSettings } = useSettings(); const { user } = useUser(); useEffect(() => { if ('serviceWorker' in navigator && user?.id) { @@ -15,40 +14,12 @@ const ServiceWorkerSetup = () => { '[SW] Registration successful, scope is:', registration.scope ); - - 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) { - const res = await fetch('/api/v1/user/registerPushSubscription', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - endpoint: parsedSub.endpoint, - p256dh: parsedSub.keys.p256dh, - auth: parsedSub.keys.auth, - }), - }); - if (!res.ok) throw new Error(); - } - } }) .catch(function (error) { console.log('[SW] Service worker registration failed, error:', error); }); } - }, [ - user, - currentSettings.vapidPublic, - currentSettings.enablePushRegistration, - ]); + }, [user]); return null; }; diff --git a/src/components/UserProfile/UserSettings/UserNotificationSettings/UserNotificationsWebPush.tsx b/src/components/UserProfile/UserSettings/UserNotificationSettings/UserNotificationsWebPush.tsx deleted file mode 100644 index e338c9f0a..000000000 --- a/src/components/UserProfile/UserSettings/UserNotificationSettings/UserNotificationsWebPush.tsx +++ /dev/null @@ -1,136 +0,0 @@ -import Button from '@app/components/Common/Button'; -import LoadingSpinner from '@app/components/Common/LoadingSpinner'; -import NotificationTypeSelector, { - ALL_NOTIFICATIONS, -} from '@app/components/NotificationTypeSelector'; -import { useUser } from '@app/hooks/useUser'; -import globalMessages from '@app/i18n/globalMessages'; -import defineMessages from '@app/utils/defineMessages'; -import { ArrowDownOnSquareIcon } from '@heroicons/react/24/outline'; -import type { UserSettingsNotificationsResponse } from '@server/interfaces/api/userSettingsInterfaces'; -import { Form, Formik } from 'formik'; -import { useRouter } from 'next/router'; -import { useIntl } from 'react-intl'; -import { useToasts } from 'react-toast-notifications'; -import useSWR, { mutate } from 'swr'; - -const messages = defineMessages( - 'components.UserProfile.UserSettings.UserNotificationSettings', - { - webpushsettingssaved: 'Web push notification settings saved successfully!', - webpushsettingsfailed: 'Web push notification settings failed to save.', - } -); - -const UserWebPushSettings = () => { - const intl = useIntl(); - const { addToast } = useToasts(); - const router = useRouter(); - const { user } = useUser({ id: Number(router.query.userId) }); - const { - data, - error, - mutate: revalidate, - } = useSWR( - user ? `/api/v1/user/${user?.id}/settings/notifications` : null - ); - - if (!data && !error) { - return ; - } - - return ( - { - try { - const res = await fetch( - `/api/v1/user/${user?.id}/settings/notifications`, - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - 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, - }, - }), - } - ); - if (!res.ok) throw new Error(); - 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 - } - /> -
-
- - - -
-
- - ); - }} -
- ); -}; - -export default UserWebPushSettings; diff --git a/src/components/UserProfile/UserSettings/UserNotificationSettings/UserNotificationsWebPush/DeviceItem.tsx b/src/components/UserProfile/UserSettings/UserNotificationSettings/UserNotificationsWebPush/DeviceItem.tsx new file mode 100644 index 000000000..59da71093 --- /dev/null +++ b/src/components/UserProfile/UserSettings/UserNotificationSettings/UserNotificationsWebPush/DeviceItem.tsx @@ -0,0 +1,110 @@ +import ConfirmButton from '@app/components/Common/ConfirmButton'; +import globalMessages from '@app/i18n/globalMessages'; +import defineMessages from '@app/utils/defineMessages'; +import { + ComputerDesktopIcon, + DevicePhoneMobileIcon, + TrashIcon, +} from '@heroicons/react/24/solid'; +import { useIntl } from 'react-intl'; +import { UAParser } from 'ua-parser-js'; + +interface DeviceItemProps { + disablePushNotifications: (p256dh: string) => void; + device: { + endpoint: string; + p256dh: string; + auth: string; + userAgent: string; + createdAt: Date; + }; +} + +const messages = defineMessages( + 'components.UserProfile.UserSettings.UserNotificationSettings.UserNotificationsWebPush', + { + operatingsystem: 'Operating System', + browser: 'Browser', + engine: 'Engine', + deletesubscription: 'Delete Subscription', + unknown: 'Unknown', + } +); + +const DeviceItem = ({ disablePushNotifications, device }: DeviceItemProps) => { + const intl = useIntl(); + + return ( +
+
+
+
+ {UAParser(device.userAgent).device.type === 'mobile' ? ( + + ) : ( + + )} +
+
+
+ {device.createdAt + ? intl.formatDate(device.createdAt, { + year: 'numeric', + month: 'long', + day: 'numeric', + }) + : 'N/A'} +
+
+ {device.userAgent + ? UAParser(device.userAgent).device.model + : intl.formatMessage(messages.unknown)} +
+
+
+
+
+ + {intl.formatMessage(messages.operatingsystem)} + + + {device.userAgent ? UAParser(device.userAgent).os.name : 'N/A'} + +
+
+ + {intl.formatMessage(messages.browser)} + + + {device.userAgent + ? UAParser(device.userAgent).browser.name + : 'N/A'} + +
+
+ + {intl.formatMessage(messages.engine)} + + + {device.userAgent + ? UAParser(device.userAgent).engine.name + : 'N/A'} + +
+
+
+
+ disablePushNotifications(device.p256dh)} + confirmText={intl.formatMessage(globalMessages.areyousure)} + className="w-full" + > + + {intl.formatMessage(messages.deletesubscription)} + +
+
+ ); +}; + +export default DeviceItem; diff --git a/src/components/UserProfile/UserSettings/UserNotificationSettings/UserNotificationsWebPush/index.tsx b/src/components/UserProfile/UserSettings/UserNotificationSettings/UserNotificationsWebPush/index.tsx new file mode 100644 index 000000000..de438e3ad --- /dev/null +++ b/src/components/UserProfile/UserSettings/UserNotificationSettings/UserNotificationsWebPush/index.tsx @@ -0,0 +1,378 @@ +import Alert from '@app/components/Common/Alert'; +import Button from '@app/components/Common/Button'; +import LoadingSpinner from '@app/components/Common/LoadingSpinner'; +import NotificationTypeSelector, { + ALL_NOTIFICATIONS, +} from '@app/components/NotificationTypeSelector'; +import DeviceItem from '@app/components/UserProfile/UserSettings/UserNotificationSettings/UserNotificationsWebPush/DeviceItem'; +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 { 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 { Form, Formik } from 'formik'; +import { useRouter } from 'next/router'; +import { useEffect, useState } from 'react'; +import { useIntl } from 'react-intl'; +import { useToasts } from 'react-toast-notifications'; +import useSWR, { mutate } from 'swr'; + +const messages = defineMessages( + 'components.UserProfile.UserSettings.UserNotificationSettings.UserNotificationsWebPush', + { + 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 enabling web push.', + disablingwebpusherror: 'Something went wrong while disabling web push.', + } +); + +const UserWebPushSettings = () => { + const intl = useIntl(); + const { addToast } = useToasts(); + const router = useRouter(); + const { user } = useUser({ id: Number(router.query.userId) }); + const { currentSettings } = useSettings(); + const [webPushEnabled, setWebPushEnabled] = useState(false); + const { + data, + error, + mutate: revalidate, + } = 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 + 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)); + + if (parsedSub.keys.p256dh && parsedSub.keys.auth) { + const res = await fetch('/api/v1/user/registerPushSubscription', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + endpoint: parsedSub.endpoint, + p256dh: parsedSub.keys.p256dh, + auth: parsedSub.keys.auth, + userAgent: navigator.userAgent, + }), + }); + if (!res.ok) { + throw new Error(res.statusText); + } + 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(); + }); + } + }; + + // 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) => { + const parsedSub = JSON.parse(JSON.stringify(subscription)); + + const res = await fetch( + `/api/v1/user/${user?.id}/pushSubscription/${ + p256dh ? p256dh : parsedSub.keys.p256dh + }`, + { + method: 'DELETE', + } + ); + if (!res.ok) { + throw new Error(res.statusText); + } + 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(); + }); + }); + } + }; + + // 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 response = await fetch( + `/api/v1/user/${user.id}/pushSubscription/${parsedKey.keys.p256dh}` + ); + + if (!response.ok) { + throw new Error(response.statusText); + } + + const currentUserPushSub = { + data: (await response.json()) as UserPushSubscription, + }; + + if (currentUserPushSub.data.p256dh !== parsedKey.keys.p256dh) { + 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 + ); + }); + } + }, [user?.id]); + + if (!data && !error) { + return ; + } + + return ( + <> + { + try { + const res = await fetch( + `/api/v1/user/${user?.id}/settings/notifications`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + 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, + }, + }), + } + ); + if (!res.ok) { + throw new Error(res.statusText); + } + 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 ? ( + 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) => ( +
+ +
+ )) + ) : ( + <> + + + )} +
+
+ + ); +}; + +export default UserWebPushSettings; diff --git a/src/i18n/locale/en.json b/src/i18n/locale/en.json index 50df170b2..121f6882e 100644 --- a/src/i18n/locale/en.json +++ b/src/i18n/locale/en.json @@ -1339,6 +1339,26 @@ "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.browser": "Browser", + "components.UserProfile.UserSettings.UserNotificationSettings.UserNotificationsWebPush.created": "Created", + "components.UserProfile.UserSettings.UserNotificationSettings.UserNotificationsWebPush.deletesubscription": "Delete Subscription", + "components.UserProfile.UserSettings.UserNotificationSettings.UserNotificationsWebPush.device": "Device", + "components.UserProfile.UserSettings.UserNotificationSettings.UserNotificationsWebPush.disablewebpush": "Disable web push", + "components.UserProfile.UserSettings.UserNotificationSettings.UserNotificationsWebPush.disablingwebpusherror": "Something went wrong while disabling web push.", + "components.UserProfile.UserSettings.UserNotificationSettings.UserNotificationsWebPush.enablewebpush": "Enable web push", + "components.UserProfile.UserSettings.UserNotificationSettings.UserNotificationsWebPush.enablingwebpusherror": "Something went wrong while enabling web push.", + "components.UserProfile.UserSettings.UserNotificationSettings.UserNotificationsWebPush.engine": "Engine", + "components.UserProfile.UserSettings.UserNotificationSettings.UserNotificationsWebPush.managedevices": "Manage Devices", + "components.UserProfile.UserSettings.UserNotificationSettings.UserNotificationsWebPush.nodevicestoshow": "You have no web push subscriptions to show.", + "components.UserProfile.UserSettings.UserNotificationSettings.UserNotificationsWebPush.operatingsystem": "Operating System", + "components.UserProfile.UserSettings.UserNotificationSettings.UserNotificationsWebPush.subscriptiondeleted": "Subscription deleted.", + "components.UserProfile.UserSettings.UserNotificationSettings.UserNotificationsWebPush.subscriptiondeleteerror": "Something went wrong while deleting the user subscription.", + "components.UserProfile.UserSettings.UserNotificationSettings.UserNotificationsWebPush.type": "type", + "components.UserProfile.UserSettings.UserNotificationSettings.UserNotificationsWebPush.unknown": "Unknown", + "components.UserProfile.UserSettings.UserNotificationSettings.UserNotificationsWebPush.webpushhasbeendisabled": "Web push has been disabled.", + "components.UserProfile.UserSettings.UserNotificationSettings.UserNotificationsWebPush.webpushhasbeenenabled": "Web push has been enabled.", + "components.UserProfile.UserSettings.UserNotificationSettings.UserNotificationsWebPush.webpushsettingsfailed": "Web push notification settings failed to save.", + "components.UserProfile.UserSettings.UserNotificationSettings.UserNotificationsWebPush.webpushsettingssaved": "Web push notification settings saved successfully!", "components.UserProfile.UserSettings.UserNotificationSettings.deviceDefault": "Device Default", "components.UserProfile.UserSettings.UserNotificationSettings.discordId": "User ID", "components.UserProfile.UserSettings.UserNotificationSettings.discordIdTip": "The multi-digit ID number associated with your user account", @@ -1378,8 +1398,6 @@ "components.UserProfile.UserSettings.UserNotificationSettings.validationTelegramChatId": "You must provide a valid chat ID", "components.UserProfile.UserSettings.UserNotificationSettings.validationTelegramMessageThreadId": "The thread/topic ID must be a positive whole number", "components.UserProfile.UserSettings.UserNotificationSettings.webpush": "Web Push", - "components.UserProfile.UserSettings.UserNotificationSettings.webpushsettingsfailed": "Web push notification settings failed to save.", - "components.UserProfile.UserSettings.UserNotificationSettings.webpushsettingssaved": "Web push notification settings saved successfully!", "components.UserProfile.UserSettings.UserPasswordChange.confirmpassword": "Confirm Password", "components.UserProfile.UserSettings.UserPasswordChange.currentpassword": "Current Password", "components.UserProfile.UserSettings.UserPasswordChange.newpassword": "New Password", diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index 9e87cbdf0..1b29d41e8 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -242,7 +242,9 @@ CoreApp.getInitialProps = async (initialProps) => { if (ctx.res) { // Check if app is initialized and redirect if necessary const res = await fetch( - `http://localhost:${process.env.PORT || 5055}/api/v1/settings/public` + `http://${process.env.HOST || 'localhost'}:${ + process.env.PORT || 5055 + }/api/v1/settings/public` ); if (!res.ok) throw new Error(); currentSettings = await res.json(); @@ -260,7 +262,9 @@ CoreApp.getInitialProps = async (initialProps) => { try { // Attempt to get the user by running a request to the local api const res = await fetch( - `http://localhost:${process.env.PORT || 5055}/api/v1/auth/me`, + `http://${process.env.HOST || 'localhost'}:${ + process.env.PORT || 5055 + }/api/v1/auth/me`, { headers: ctx.req && ctx.req.headers.cookie diff --git a/src/pages/collection/[collectionId]/index.tsx b/src/pages/collection/[collectionId]/index.tsx index b0c47b17c..da9c6bf03 100644 --- a/src/pages/collection/[collectionId]/index.tsx +++ b/src/pages/collection/[collectionId]/index.tsx @@ -14,9 +14,9 @@ export const getServerSideProps: GetServerSideProps< CollectionPageProps > = async (ctx) => { const res = await fetch( - `http://localhost:${process.env.PORT || 5055}/api/v1/collection/${ - ctx.query.collectionId - }`, + `http://${process.env.HOST || 'localhost'}:${ + process.env.PORT || 5055 + }/api/v1/collection/${ctx.query.collectionId}`, { headers: ctx.req?.headers?.cookie ? { cookie: ctx.req.headers.cookie } diff --git a/src/pages/movie/[movieId]/index.tsx b/src/pages/movie/[movieId]/index.tsx index be0d2aa5a..cf2b11b9c 100644 --- a/src/pages/movie/[movieId]/index.tsx +++ b/src/pages/movie/[movieId]/index.tsx @@ -14,9 +14,9 @@ export const getServerSideProps: GetServerSideProps = async ( ctx ) => { const res = await fetch( - `http://localhost:${process.env.PORT || 5055}/api/v1/movie/${ - ctx.query.movieId - }`, + `http://${process.env.HOST || 'localhost'}:${ + process.env.PORT || 5055 + }/api/v1/movie/${ctx.query.movieId}`, { headers: ctx.req?.headers?.cookie ? { cookie: ctx.req.headers.cookie } diff --git a/src/pages/tv/[tvId]/index.tsx b/src/pages/tv/[tvId]/index.tsx index 3961b157a..36fba5fcc 100644 --- a/src/pages/tv/[tvId]/index.tsx +++ b/src/pages/tv/[tvId]/index.tsx @@ -14,7 +14,9 @@ export const getServerSideProps: GetServerSideProps = async ( ctx ) => { const res = await fetch( - `http://localhost:${process.env.PORT || 5055}/api/v1/tv/${ctx.query.tvId}`, + `http://${process.env.HOST || 'localhost'}:${ + process.env.PORT || 5055 + }/api/v1/tv/${ctx.query.tvId}`, { headers: ctx.req?.headers?.cookie ? { cookie: ctx.req.headers.cookie }