feat: added a manageable device list

pull/3421/head
Brandon 2 years ago committed by Brandon Cohen
parent 2e36c38818
commit e3012708c4

@ -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.

@ -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",

@ -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<UserPushSubscription>) {
Object.assign(this, init);
}

@ -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) => {

@ -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<UserSettingsNotificationsResponse>(
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 (
<Formik
initialValues={{
types: data?.notificationTypes.webpush ?? ALL_NOTIFICATIONS,
}}
enableReinitialize
onSubmit={async (values) => {
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 (
<Form className="section">
<NotificationTypeSelector
user={user}
currentTypes={values.types}
onUpdate={(newTypes) => {
setFieldValue('types', newTypes);
setFieldTouched('types');
}}
error={
errors.types && touched.types
? (errors.types as string)
: undefined
<>
<Formik
initialValues={{
types: data?.notificationTypes.webpush ?? ALL_NOTIFICATIONS,
}}
enableReinitialize
onSubmit={async (values) => {
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,
},
}
/>
<div className="actions">
<div className="flex justify-end">
<span className="ml-3 inline-flex rounded-md shadow-sm">
<Button
buttonType={`${webPushEnabled ? 'danger' : 'primary'}`}
type="button"
onClick={() =>
webPushEnabled
? disablePushNotifications()
: enablePushNotifications()
}
>
{webPushEnabled ? (
<CloudArrowDownIcon />
) : (
<CloudArrowUpIcon />
)}
<span>
{webPushEnabled
? intl.formatMessage(messages.disablewebpush)
: intl.formatMessage(messages.enablewebpush)}
</span>
</Button>
</span>
<span className="ml-3 inline-flex rounded-md shadow-sm">
<Button
buttonType="primary"
type="submit"
disabled={isSubmitting || !isValid}
>
<ArrowDownOnSquareIcon />
<span>
{isSubmitting
? intl.formatMessage(globalMessages.saving)
: intl.formatMessage(globalMessages.save)}
</span>
</Button>
</span>
);
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 (
<Form className="section">
<NotificationTypeSelector
user={user}
currentTypes={values.types}
onUpdate={(newTypes) => {
setFieldValue('types', newTypes);
setFieldTouched('types');
}}
error={
errors.types && touched.types
? (errors.types as string)
: undefined
}
/>
<div className="actions">
<div className="flex justify-end">
<span className="ml-3 inline-flex rounded-md shadow-sm">
<Button
buttonType={`${webPushEnabled ? 'danger' : 'primary'}`}
type="button"
onClick={() =>
webPushEnabled
? disablePushNotifications()
: enablePushNotifications()
}
>
{webPushEnabled ? (
<CloudArrowDownIcon />
) : (
<CloudArrowUpIcon />
)}
<span>
{webPushEnabled
? intl.formatMessage(messages.disablewebpush)
: intl.formatMessage(messages.enablewebpush)}
</span>
</Button>
</span>
<span className="ml-3 inline-flex rounded-md shadow-sm">
<Button
buttonType="primary"
type="submit"
disabled={isSubmitting || !isValid}
>
<ArrowDownOnSquareIcon />
<span>
{isSubmitting
? intl.formatMessage(globalMessages.saving)
: intl.formatMessage(globalMessages.save)}
</span>
</Button>
</span>
</div>
</div>
</div>
</Form>
);
}}
</Formik>
</Form>
);
}}
</Formik>
<div className="mt-10 mb-6">
<h3 className="heading">
{intl.formatMessage(messages.managedevices)}
</h3>
{dataDevices?.length ? (
<Table>
<thead>
<tr>
<Table.TH>{intl.formatMessage(messages.type)}</Table.TH>
<Table.TH>{intl.formatMessage(messages.device)}</Table.TH>
<Table.TH>{intl.formatMessage(messages.created)}</Table.TH>
<Table.TH />
</tr>
</thead>
<Table.TBody>
{dataDevices?.map((device) => (
<tr
key={`device-list-${device.p256dh}`}
className="bg-gray-700"
>
<Table.TD>
<div className="flex items-center">
<div className="h-8 w-8 flex-shrink-0">
{UAParser(device.userAgent).device.type === 'mobile' ? (
<DevicePhoneMobileIcon />
) : (
<ComputerDesktopIcon />
)}
</div>
</div>
</Table.TD>
<Table.TD>
{device.userAgent
? UAParser(device.userAgent).device.model
: 'Unknown'}
</Table.TD>
<Table.TD>
{device.createdAt
? intl.formatDate(device.createdAt, {
year: 'numeric',
month: 'long',
day: 'numeric',
})
: 'Unknown'}
</Table.TD>
<Table.TD alignText="right">
<Button
buttonType="danger"
onClick={() => disablePushNotifications(device.p256dh)}
>
<TrashIcon />
</Button>
</Table.TD>
</tr>
))}
</Table.TBody>
</Table>
) : (
<>
<div className="mt-5" />
<Alert
title={intl.formatMessage(messages.nodevicestoshow)}
type="info"
/>
</>
)}
</div>
</>
);
};

@ -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"

Loading…
Cancel
Save