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 💻 |
 JackOXI 💻 |
 Stancu Florin 💻 |
+  Lukas Miklosko 💻 |
+  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 (
-
- );
- }}
-
- );
-};
-
-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 (
+
+ );
+ }}
+
+
+
+ {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 }