Commit b34f63cd authored by Emmanuel Ormancey's avatar Emmanuel Ormancey Committed by Carina Antunes
Browse files

[#28][DEVICES] Manage and Save device data

parent 0791e086
Pipeline #2268186 passed with stages
in 6 minutes and 8 seconds
......@@ -2,3 +2,4 @@ REACT_APP_BASE_URL=https://api-test-notifications-service.apptest.cern.ch
REACT_APP_OAUTH_REDIRECT_URL=https://test-notifications-service.web.cern.ch/redirect
REACT_APP_OAUTH_LOGOUT_URL=https://test-notifications-service.web.cern.ch/logout
REACT_APP_ARCHIVE_URL=https://cern.ch/notifications-archives
REACT_APP_VAPID_PUBLICKEY=BDC_Z4KiJzs0II7qLJT3DvlVJ0qcWkCMA6u7Q8B1QnpJeax5kShz6-PjoTM7HlTJua1qlXVGLbiZRT3SBM7ZzaY
......@@ -24,4 +24,5 @@ yarn-error.log*
.idea
.vscode
yarn.lock
src/*.css
# generated css
src/**/*.css
......@@ -29,3 +29,6 @@ Once it is built, we can run the container with the app listening on `http://loc
```
docker run -p 3000:3000 web-portal
```
## Testing locally push registration
Push registration requires SSL and a valid cert, even on localhost.
......@@ -12721,10 +12721,9 @@
"integrity": "sha1-1PRWKwzjaW5BrFLQ4ALlemNdxtw="
},
"prettier": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-2.1.2.tgz",
"integrity": "sha512-16c7K+x4qVlJg9rEbXl7HEGmQyZlG4R9AgP+oHKRMsMsuk8s+ATStlf1NpDqyBI1HpVyfjLOeMhH2LvuNvV5Vg==",
"dev": true
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-2.2.1.tgz",
"integrity": "sha512-PqyhM2yCjg/oKkFPtTGUojv7gnZAoG80ttl45O6x2Ug/rMJw4wcc9k6aaf2hibP7BGVCCM33gZoGjyvt9mm16Q=="
},
"prettier-linter-helpers": {
"version": "1.0.0",
......@@ -13782,6 +13781,11 @@
"unicode-match-property-value-ecmascript": "^1.2.0"
}
},
"register-service-worker": {
"version": "1.7.2",
"resolved": "https://registry.npmjs.org/register-service-worker/-/register-service-worker-1.7.2.tgz",
"integrity": "sha512-CiD3ZSanZqcMPRhtfct5K9f7i3OLCcBBWsJjLh1gW9RO/nS94sVzY59iS+fgYBOBqaBpf4EzfqUF3j9IG+xo8A=="
},
"registry-auth-token": {
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/registry-auth-token/-/registry-auth-token-3.3.2.tgz",
......
......@@ -17,6 +17,7 @@
"jwt-decode": "^2.2.0",
"node-sass-chokidar": "^1.3.0",
"npm-run-all": "^4.1.3",
"prettier": "^2.2.1",
"prop-types": "^15.7.2",
"qs": "^6.5.2",
"react": "^16.12.0",
......@@ -31,6 +32,7 @@
"redux-api-middleware": "^2.3.0",
"redux-form": "^8.3.0",
"redux-thunk": "^2.3.0",
"register-service-worker": "^1.7.2",
"semantic-ui-react": "^2.0.0",
"serve": "^11.3.0"
},
......@@ -58,9 +60,8 @@
"eslint-plugin-react": "^7.10.0",
"eslint-plugin-react-hooks": "^4.0.0",
"husky": "^4.0.0",
"prettier": "^2.0.0",
"pretty-quick": "^3.0.0",
"lint-staged": "^10.4.0"
"lint-staged": "^10.4.0",
"pretty-quick": "^3.0.0"
},
"lint-staged": {
"src/**/*.{js,jsx,ts,tsx,json,css,scss,md}": [
......
self.addEventListener('push', function (event) {
if (event.data) {
console.log('Push event text: ', event.data.text());
console.log(event.data);
var options = {
icon: '/images/Logo-Outline-web-Blue100.png',
badge: '/images/Logo-Outline-web-Blue100.png',
};
let title = 'CERN Notification';
try {
const blob = event.data.json(); //JSON.parse(event.data.text());
options.body = blob.message;
if (blob.title) title = blob.title;
if (blob.image) options.image = blob.image;
if (blob.url) options.data = {url: blob.url};
} catch {}
// fallback if no json blob in message
if (!options.body) options.body = event.data.text();
event.waitUntil(self.registration.showNotification(title, options));
} else {
console.log('Push event but no data');
}
});
self.addEventListener('notificationclick', function (event) {
if (event.notification.data && event.notification.data.url) {
event.notification.close();
event.waitUntil(clients.openWindow(event.notification.data.url));
} else console.log('Url undefined.');
});
import {RSAA} from 'redux-api-middleware';
import {withAuth} from 'auth/utils/authUtils';
export const GET_DEVICES = 'GET_DEVICES';
export const GET_DEVICES_SUCCESS = 'GET_DEVICES_SUCCESS';
export const GET_DEVICES_FAILURE = 'GET_DEVICES_FAILURE';
export const CREATE_DEVICE = 'CREATE_DEVICE';
export const CREATE_DEVICE_SUCCESS = 'CREATE_DEVICE_SUCCESS';
export const CREATE_DEVICE_FAILURE = 'CREATE_DEVICE_FAILURE';
export const DELETE_DEVICE = 'DELETE_DEVICE';
export const DELETE_DEVICE_SUCCESS = 'DELETE_DEVICE_SUCCESS';
export const DELETE_DEVICE_FAILURE = 'DELETE_DEVICE_FAILURE';
export const TEST_DEVICE = 'TEST_DEVICE';
export const TEST_DEVICE_SUCCESS = 'TEST_DEVICE_SUCCESS';
export const TEST_DEVICE_FAILURE = 'TEST_DEVICE_FAILURE';
export const getDevices = () => ({
[RSAA]: {
endpoint: `${process.env.REACT_APP_BASE_URL}/devices/`,
method: 'GET',
credentials: 'include',
headers: withAuth({'Content-Type': 'application/json'}),
types: [GET_DEVICES, GET_DEVICES_SUCCESS, GET_DEVICES_FAILURE],
},
});
export const createDevice = device => ({
[RSAA]: {
endpoint: `${process.env.REACT_APP_BASE_URL}/devices`,
method: 'POST',
credentials: 'include',
headers: withAuth({'Content-Type': 'application/json'}),
body: JSON.stringify({device}),
types: [CREATE_DEVICE, CREATE_DEVICE_SUCCESS, CREATE_DEVICE_FAILURE],
},
});
export const deleteDeviceById = deviceId => ({
[RSAA]: {
endpoint: `${process.env.REACT_APP_BASE_URL}/devices/${deviceId}`,
method: 'DELETE',
credentials: 'include',
headers: withAuth({'Content-Type': 'application/json'}),
types: [DELETE_DEVICE, DELETE_DEVICE_SUCCESS, DELETE_DEVICE_FAILURE],
},
});
export const tryBrowserPushNotification = deviceId => ({
[RSAA]: {
endpoint: `${process.env.REACT_APP_BASE_URL}/devices/${deviceId}`,
method: 'POST',
credentials: 'include',
headers: withAuth({'Content-Type': 'application/json'}),
types: [TEST_DEVICE, TEST_DEVICE_SUCCESS, TEST_DEVICE_FAILURE],
},
});
import React, {useEffect} from 'react';
import {bindActionCreators} from 'redux';
import {connect} from 'react-redux';
import {Header, Divider} from 'semantic-ui-react';
import DevicesList from 'components/devices/DevicesList';
import * as devicesActions from 'actions/devices';
function DevicesGlobal({devices, getDevices}) {
useEffect(() => {
getDevices();
}, [getDevices]);
console.log(devices);
return (
<div
style={{
width: 933,
marginTop: 100,
marginLeft: 'auto',
marginRight: 'auto',
}}
>
<Header as="h3">
Devices List
<Header.Subheader>
Register your devices and browsers here for targeted channel notifications
</Header.Subheader>
</Header>
<Divider />
<DevicesList devices={devices} allowDelete />
</div>
);
}
export default connect(
state => ({
devices: state.devices.userDevices || [],
}),
dispatch =>
bindActionCreators(
{
getDevices: devicesActions.getDevices,
},
dispatch
)
)(DevicesGlobal);
......@@ -12,6 +12,7 @@ import CreateChannelPage from '../../../channels/pages/CreateChannelPage/CreateC
import CERNToolBar from '../../components/CERNToolBar/CERNToolBar';
import EditChannelPage from '../../../channels/pages/EditChannelPage/EditChannelPage';
import ChannelGlobalPreferences from '../../../channels/pages/ChannelGlobalPreferences/ChannelGlobalPreferences';
import DevicesGlobal from 'common/pages/Devices/DevicesGlobal';
const MainPage = props => {
const {snackbars, isAuthenticated} = props;
......@@ -26,6 +27,7 @@ const MainPage = props => {
<Menu.Item as={Link} name="Channels List" to="/main/channels" />
<Menu.Item as={Link} name="Create channel" to="/main/channels/create" />
<Menu.Item as={Link} name="Preferences" to="/main/preferences" />
<Menu.Item as={Link} name="Devices" to="/main/devices" />
</Menu>
)}
......@@ -48,6 +50,7 @@ const MainPage = props => {
/>
<Route path="/main/channels/:channelId" component={EditChannelPage} key={4} />
<Route path="/main/preferences" component={ChannelGlobalPreferences} key={5} />
<Route path="/main/devices" component={DevicesGlobal} key={6} />
</Switch>
</div>
</div>
......
import React, {useState} from 'react';
import {bindActionCreators} from 'redux';
import {connect} from 'react-redux';
import {Form, Modal, Button} from 'semantic-ui-react';
import * as deviceActions from 'actions/devices';
import * as showSnackBarActionCreators from '../../common/actions/Snackbar';
import ServiceWorkerRegistration from './ServiceWorkerRegistration';
import getClientInformation from '../../utils/user-agent';
const clientInformation = getClientInformation();
const AddDevice = ({createDevice, showSnackbar}) => {
const [deviceName, setDeviceName] = useState(clientInformation);
const [deviceInfo, setDeviceInfo] = useState(navigator.userAgent);
const [deviceType, setDeviceType] = useState('BROWSER');
const [deviceToken, setDeviceToken] = useState('');
const [modalOpen, setModalOpen] = useState(false);
const [submitButtonDisabled, setSubmitButtonDisabled] = useState(false);
const resetFormValues = () => {
setDeviceName(clientInformation); // initialize with platform name, let user edit for easy remembering
setDeviceInfo(navigator.userAgent);
setDeviceType('BROWSER'); // Browser only for now.
setDeviceToken('');
};
const saveSubscriptionBlob = subscriptionBlob => {
console.log('event received');
setDeviceToken(JSON.stringify(subscriptionBlob));
};
return (
<Modal
trigger={<Button onClick={() => setModalOpen(true)}>Add device</Button>}
open={modalOpen}
onClose={() => setModalOpen(false)}
>
<Modal.Header>Add device</Modal.Header>
<Modal.Content>
<Modal.Description>
<Form>
<Form.Field>
<Form.Input
label="Device name"
placeholder="Device name"
value={deviceName}
onChange={(e, d) => setDeviceName(d.value)}
/>
</Form.Field>
<p>
Specify a device name or computer name for easy remembering and targeting
notifications.
</p>
<ServiceWorkerRegistration onSubscription={saveSubscriptionBlob} />
<Form.Button
disabled={submitButtonDisabled}
onClick={() => {
if (deviceToken) {
setSubmitButtonDisabled(true);
createDevice({
name: deviceName,
info: deviceInfo,
type: deviceType,
token: deviceToken,
}).then(({error}) => {
setSubmitButtonDisabled(false);
if (error) {
showSnackbar('An error occurred while adding your device', 'error');
} else {
showSnackbar('The device has been added successfully', 'success');
resetFormValues();
setModalOpen(false);
}
});
} else {
showSnackbar(
'Notification subscription data missing, please try to toggle subscription again.',
'error'
);
}
}}
>
Submit
</Form.Button>
</Form>
</Modal.Description>
</Modal.Content>
</Modal>
);
};
export default connect(null, dispatch =>
bindActionCreators(
{
createDevice: deviceActions.createDevice,
...showSnackBarActionCreators,
},
dispatch
)
)(AddDevice);
import React from 'react';
import PropTypes from 'prop-types';
import {bindActionCreators} from 'redux';
import {connect} from 'react-redux';
import {Button, Table} from 'semantic-ui-react';
import * as deviceActions from 'actions/devices';
import AddDevice from 'components/devices/AddDevice';
const baseColumns = [
{id: 'name', label: 'Name'},
{id: 'info', label: 'Information'},
{id: 'type', label: 'Device type'},
];
function DevicesList({
devices,
allowAdd,
allowDelete,
deleteDeviceById,
tryBrowserPushNotification,
}) {
return (
<>
<Table striped>
<Table.Header>
<Table.Row>
{baseColumns.map(column => (
<Table.HeaderCell key={column.id}>{column.label}</Table.HeaderCell>
))}
<Table.HeaderCell />
</Table.Row>
</Table.Header>
<Table.Body>
{devices.map(device => {
return (
<Table.Row key={device.id}>
<Table.Cell>{device.name}</Table.Cell>
<Table.Cell>{device.info}</Table.Cell>
<Table.Cell>{device.type}</Table.Cell>
<Table.Cell singleLine>
{device.type === 'BROWSER' && (
<Button
content="Try me"
onClick={() => tryBrowserPushNotification(device.id)}
color="green"
size="mini"
/>
)}
{allowDelete && (
<Button icon="trash" onClick={() => deleteDeviceById(device.id)} size="mini" />
)}
</Table.Cell>
</Table.Row>
);
})}
</Table.Body>
</Table>
{allowAdd && <AddDevice />}
</>
);
}
DevicesList.propTypes = {
devices: PropTypes.arrayOf(PropTypes.object).isRequired,
allowAdd: PropTypes.bool,
allowDelete: PropTypes.bool,
deleteDeviceById: PropTypes.func.isRequired,
tryBrowserPushNotification: PropTypes.func.isRequired,
};
DevicesList.defaultProps = {
allowAdd: true,
allowDelete: false,
};
const mapDispatchToProps = dispatch =>
bindActionCreators(
{
deleteDeviceById: deviceActions.deleteDeviceById,
tryBrowserPushNotification: deviceActions.tryBrowserPushNotification,
},
dispatch
);
export default connect(null, mapDispatchToProps)(DevicesList);
import React, {useState, useEffect} from 'react';
import {register, unregister} from 'register-service-worker';
import {Form, Message, Checkbox, Popup} from 'semantic-ui-react';
function ServiceWorkerRegistration({onSubscription}) {
const [status, setStatus] = useState([]);
const [isEnabled, setIsEnabled] = useState(false);
useEffect(() => {
// Anything in here is fired on component mount.
navigator.serviceWorker.getRegistrations('/').then(function (registrations) {
if (registrations && registrations.length > 0) setIsEnabled(true);
});
}, []);
function registerWorker() {
Notification.requestPermission(function (result) {
console.log('Push Permission User choice: ', result);
if (result !== 'granted') {
console.log('No notification permission granted!');
setStatus([...status, 'No notification permission granted!']);
} else {
try {
register('/serviceWorker.js', {
registrationOptions: {scope: '/'},
ready(registration) {
console.log('Service worker is active.');
setStatus(prevState => [...prevState, 'Service worker is active.']);
// This will be called only once when the service worker is installed for first time.
try {
const applicationServerKey = urlB64ToUint8Array(
process.env.REACT_APP_VAPID_PUBLICKEY
);
const subscriptionOptions = {applicationServerKey, userVisibleOnly: true};
registration.pushManager.subscribe(subscriptionOptions).then(subscription => {
console.log('subscribed to pushManager');
onSubscription(subscription);
});
} catch (err) {
console.log('Error', err);
}
},
registered(registration) {
console.log('Service worker has been registered.');
setStatus(prevState => [...prevState, 'Service worker has been registered.']);
setIsEnabled(true);
},
cached(registration) {
console.log('Content has been cached for offline use.');
setStatus(prevState => [...prevState, 'Content has been cached for offline use.']);
},
updatefound(registration) {
console.log('New content is downloading.');
setStatus(prevState => [...prevState, 'New content is downloading.']);
},
updated(registration) {
console.log('New content is available; please refresh.');
setStatus(prevState => [...prevState, 'New content is available; please refresh.']);
},
offline() {
console.log('No internet connection found. App is running in offline mode.');
setStatus(prevState => [
...prevState,
'No internet connection found. App is running in offline mode.',
]);
},
error(error) {
console.error('Error during service worker registration:', error);
setStatus(prevState => [
...prevState,
'Error during service worker registration:' + error.message,
]);
},
});
} catch (regErr) {
console.log('Error', regErr);
setStatus(prevState => [
...prevState,
'Error during service worker registration:' + regErr.message,
]);
}
}
});
}
function unRegisterWorker() {
unregister();
setIsEnabled(false);
console.log('service worker unregistered');
setStatus(prevState => [...prevState, 'service worker unregistered']);
}
// urlB64ToUint8Array is a magic function that will encode the base64 public key
// to Array buffer which is needed by the subscription option
function urlB64ToUint8Array(base64String) {
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
const rawData = atob(base64);
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
}
function toggleRegistration() {
if (isEnabled) unRegisterWorker();
else registerWorker();
}
return (
<Form.Field>
<label>Register for Push Notifications</label>
<Popup
trigger={<Checkbox checked={isEnabled} onChange={toggleRegistration} toggle />}
content="Enable Push Notifications"
position="right center"
/>
<Message info>
Toggle to activate push notifications, then save the device by clicking submit button below.
</Message>
{status && status.length > 0 && (
<Message size="tiny">
<Message.Header>Status</Message.Header>
{status.map((str, i) => (
<div key={i}>{str} </div>
))}
</Message>
)}
</Form.Field>
);
}
export default ServiceWorkerRegistration;
import {
GET_DEVICES,
GET_DEVICES_SUCCESS,