mirror of https://github.com/rancher/dashboard.git
427 lines
12 KiB
TypeScript
427 lines
12 KiB
TypeScript
import { md5 } from '@shell/utils/crypto';
|
|
import { randomStr } from '@shell/utils/string';
|
|
import { EncryptedNotification, Notification, StoredNotification } from '@shell/types/notifications';
|
|
import { encrypt, decrypt, deriveKey } from '@shell/utils/crypto/encryption';
|
|
|
|
/**
|
|
* Key used to store notifications in the browser's local storage
|
|
*/
|
|
const LOCAL_STORAGE_KEY_PREFIX = 'rancher-notifications-';
|
|
|
|
/**
|
|
* Expire notifications in seconds (14 days)
|
|
*/
|
|
const EXPIRY = 14 * 24 * 60 * 60;
|
|
|
|
/**
|
|
* Maximum number of notifications that will be kept
|
|
*/
|
|
const MAX_NOTIFICATIONS = 50;
|
|
|
|
/**
|
|
* Broadcast channel name to send changes across tabs
|
|
*/
|
|
const NOTIFICATION_CHANNEL_NAME = 'rancher-notification-sync';
|
|
|
|
/**
|
|
* Store for the UI Notification Centre
|
|
*/
|
|
interface NotificationsStore {
|
|
localStorageKey: string,
|
|
userId: string;
|
|
notifications: StoredNotification[],
|
|
encryptionKey: CryptoKey | undefined,
|
|
}
|
|
|
|
export const state = function(): NotificationsStore {
|
|
const notifications: StoredNotification[] = [];
|
|
|
|
return {
|
|
localStorageKey: '',
|
|
userId: '',
|
|
encryptionKey: undefined,
|
|
notifications,
|
|
};
|
|
};
|
|
|
|
let bc: BroadcastChannel;
|
|
|
|
/**
|
|
* Sync notifications to other tabs using the broadcast channel. Send the user id, to cover corner case
|
|
* where a stale login exists for a different user in another tab.
|
|
*/
|
|
function sync(userId: string, operation: string, param?: any) {
|
|
bc?.postMessage({
|
|
userId,
|
|
operation,
|
|
param
|
|
});
|
|
}
|
|
|
|
async function saveEncryptedNotification(getters: any, notification: Notification) {
|
|
const toEncrypt: EncryptedNotification = {
|
|
title: notification.title,
|
|
message: notification.message,
|
|
level: notification.level,
|
|
primaryAction: notification.primaryAction,
|
|
secondaryAction: notification.secondaryAction,
|
|
};
|
|
|
|
const localStorageKey = getters['localStorageKey'];
|
|
const encryptionKey = getters['encryptionKey'];
|
|
|
|
try {
|
|
const data = JSON.stringify(toEncrypt);
|
|
const enc = await encrypt(data, encryptionKey);
|
|
|
|
window.localStorage.setItem(`${ localStorageKey }-${ notification.id }`, JSON.stringify(enc));
|
|
} catch (e) {
|
|
console.error('Unable to save notification to local storage', e); // eslint-disable-line no-console
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Sync the notifications index to local storage.
|
|
*
|
|
* We only store the non-sensitive data about the notifications in the index - the other data is stored in individual entries which are encrypted.
|
|
*/
|
|
function syncIndex(state: NotificationsStore) {
|
|
const localStorageKey = state.localStorageKey;
|
|
|
|
// We just want the id, created, read and progress properties for the index
|
|
const index = state.notifications.map((n) => ({
|
|
id: n.id,
|
|
created: n.created,
|
|
read: n.read,
|
|
progress: n.progress,
|
|
}));
|
|
|
|
try {
|
|
window.localStorage.setItem(localStorageKey, JSON.stringify(index));
|
|
} catch (e) {
|
|
console.error('Unable to save notifications index to local storage', e); // eslint-disable-line no-console
|
|
}
|
|
}
|
|
|
|
export const getters = {
|
|
all: (state: NotificationsStore) => {
|
|
return state.notifications;
|
|
},
|
|
|
|
item: (state: NotificationsStore) => {
|
|
return (id: string) => {
|
|
return state.notifications.find((i) => i.id === id);
|
|
};
|
|
},
|
|
|
|
// Count of unread notifications
|
|
unreadCount: (state: NotificationsStore) => {
|
|
return state.notifications.filter((n) => !n.read).length;
|
|
},
|
|
|
|
/**
|
|
* Local storage key includes the user key
|
|
*/
|
|
localStorageKey: (state: NotificationsStore) => {
|
|
return state.localStorageKey;
|
|
},
|
|
|
|
userId: (state: NotificationsStore) => {
|
|
return state.userId;
|
|
},
|
|
|
|
encryptionKey: (state: NotificationsStore) => {
|
|
return state.encryptionKey;
|
|
},
|
|
};
|
|
|
|
export const mutations = {
|
|
add(state: NotificationsStore, notification: Notification) {
|
|
if (!notification.id) {
|
|
notification.id = randomStr();
|
|
} else {
|
|
// Check that there is not already a notification with this id
|
|
const index = state.notifications.findIndex((n) => n.id === notification.id);
|
|
|
|
if (index !== -1) {
|
|
console.error(`Can not add a notification with the same id as an existing notification (${ notification.id })`); // eslint-disable-line no-console
|
|
|
|
return;
|
|
}
|
|
}
|
|
|
|
const stored = {
|
|
...notification,
|
|
read: false,
|
|
created: new Date()
|
|
};
|
|
|
|
// Add to top of list
|
|
state.notifications.unshift(stored);
|
|
|
|
// Check that we have not exceeded the maximum number of notifications
|
|
if (state.notifications.length > MAX_NOTIFICATIONS) {
|
|
const removed = state.notifications.pop();
|
|
|
|
if (removed) {
|
|
// Remove the encrypted data for the notification that we just removed
|
|
window.localStorage.removeItem(`${ state.localStorageKey }-${ removed.id }`);
|
|
}
|
|
}
|
|
|
|
syncIndex(state);
|
|
},
|
|
|
|
markRead(state: NotificationsStore, id: string) {
|
|
const notification = state.notifications.find((n) => n.id === id);
|
|
|
|
if (notification && !notification.read) {
|
|
notification.read = true;
|
|
}
|
|
|
|
syncIndex(state);
|
|
},
|
|
|
|
markUnread(state: NotificationsStore, id: string) {
|
|
const notification = state.notifications.find((n) => n.id === id);
|
|
|
|
if (notification && notification.read) {
|
|
notification.read = false;
|
|
}
|
|
|
|
syncIndex(state);
|
|
},
|
|
|
|
markAllRead(state: NotificationsStore) {
|
|
state.notifications.forEach((notification) => {
|
|
if (!notification.read) {
|
|
notification.read = true;
|
|
}
|
|
});
|
|
|
|
syncIndex(state);
|
|
},
|
|
|
|
update(state: NotificationsStore, notification: Partial<Notification>) {
|
|
if (notification?.id) {
|
|
const index = state.notifications.findIndex((n) => n.id === notification.id);
|
|
|
|
if (index >= 0) {
|
|
state.notifications[index] = {
|
|
...state.notifications[index],
|
|
...notification
|
|
};
|
|
}
|
|
}
|
|
|
|
syncIndex(state);
|
|
},
|
|
|
|
clearAll(state: NotificationsStore) {
|
|
// Remove the encrypted data for each notification
|
|
state.notifications.forEach((n) => {
|
|
window.localStorage.removeItem(`${ state.localStorageKey }-${ n.id }`);
|
|
});
|
|
|
|
state.notifications = [];
|
|
syncIndex(state);
|
|
},
|
|
|
|
remove(state: NotificationsStore, id: string) {
|
|
// Remove the encrypted data for the notification
|
|
window.localStorage.removeItem(`${ state.localStorageKey }-${ id }`);
|
|
|
|
state.notifications = state.notifications.filter((n) => n.id !== id);
|
|
syncIndex(state);
|
|
},
|
|
|
|
load(state: NotificationsStore, notifications: StoredNotification[]) {
|
|
state.notifications = notifications;
|
|
},
|
|
|
|
localStorageKey(state: NotificationsStore, userKey: string) {
|
|
state.localStorageKey = `${ LOCAL_STORAGE_KEY_PREFIX }${ userKey }`;
|
|
},
|
|
|
|
userId(state: NotificationsStore, userId: string) {
|
|
state.userId = userId;
|
|
},
|
|
|
|
encryptionKey(state: NotificationsStore, encryptionKey: CryptoKey) {
|
|
state.encryptionKey = encryptionKey;
|
|
},
|
|
};
|
|
|
|
export const actions = {
|
|
async add( { commit, dispatch, getters }: any, notification: Notification) {
|
|
// We encrypt the notification on add - this is the only time we will encrypt it
|
|
if (!notification.id) {
|
|
notification.id = randomStr();
|
|
}
|
|
|
|
// Need to save the encrypted notification to local storage
|
|
await saveEncryptedNotification(getters, notification);
|
|
|
|
commit('add', notification);
|
|
sync(getters['userId'], 'add', notification);
|
|
|
|
// Show a growl for the notification if necessary
|
|
dispatch('growl/notification', notification, { root: true });
|
|
},
|
|
|
|
async fromGrowl( { commit, getters }: any, notification: Notification) {
|
|
notification.id = randomStr();
|
|
|
|
// Need to save the encrypted notification to local storage
|
|
await saveEncryptedNotification(getters, notification);
|
|
|
|
commit('add', notification);
|
|
sync(getters['userId'], 'add', notification);
|
|
|
|
return notification.id;
|
|
},
|
|
|
|
update({ commit, getters }: any, notification: Notification) {
|
|
commit('update', notification);
|
|
sync(getters['userId'], 'update', notification);
|
|
},
|
|
|
|
async markRead({ commit, dispatch, getters }: any, id: string) {
|
|
commit('markRead', id);
|
|
sync(getters['userId'], 'markRead', id);
|
|
|
|
const notification = getters.item(id);
|
|
|
|
if (notification?.preference) {
|
|
await dispatch('prefs/set', notification.preference, { root: true });
|
|
}
|
|
},
|
|
|
|
async markUnread({ commit, dispatch, getters }: any, id: string) {
|
|
commit('markUnread', id);
|
|
sync(getters['userId'], 'markUnread', id);
|
|
|
|
const notification = getters.item(id) as Notification;
|
|
|
|
if (notification?.preference) {
|
|
await dispatch('prefs/set', {
|
|
key: notification.preference.key,
|
|
value: notification.preference.unsetValue || '',
|
|
}, { root: true });
|
|
}
|
|
},
|
|
|
|
async markAllRead({ commit, dispatch, getters }: any) {
|
|
commit('markAllRead');
|
|
sync(getters['userId'], 'markAllRead');
|
|
|
|
// For all notifications that have a preference, set the preference, since they are now read
|
|
const withPreference = getters.all.filter((n: Notification) => !!n.preference);
|
|
|
|
for (let i = 0; i < withPreference.length; i++) {
|
|
await dispatch('prefs/set', withPreference[i].preference, { root: true });
|
|
}
|
|
},
|
|
|
|
remove({ commit, getters }: any, id: string) {
|
|
commit('remove', id);
|
|
sync(getters['userId'], 'remove', id);
|
|
},
|
|
|
|
clearAll({ commit, getters }: any) {
|
|
commit('clearAll');
|
|
sync(getters['userId'], 'clearAll');
|
|
},
|
|
|
|
/**
|
|
* Initialize the notifications store and load the notifications from local storage
|
|
*/
|
|
async init({ commit, getters } : any, userData: any) {
|
|
const userKey = userData.id;
|
|
const userId = userData.v3User?.uuid;
|
|
|
|
if (!userKey || !userId) {
|
|
console.error('Unable to initialize notifications - required user info not available'); // eslint-disable-line no-console
|
|
|
|
return;
|
|
}
|
|
|
|
// Notifications are stored under a key for the current user, so set the local storage key based on the user id
|
|
commit('localStorageKey', md5(userKey, 'hex'));
|
|
commit('userId', userId);
|
|
|
|
let index: StoredNotification[] = [];
|
|
let notifications: StoredNotification[] = [];
|
|
const localStorageKey = getters['localStorageKey'];
|
|
|
|
let encryptionKey;
|
|
|
|
try {
|
|
encryptionKey = await deriveKey(userId);
|
|
} catch (e) {
|
|
console.error('Unable to generate encryption key for notifications', e); // eslint-disable-line no-console
|
|
|
|
return;
|
|
}
|
|
|
|
// Store the encryption key
|
|
commit('encryptionKey', encryptionKey);
|
|
|
|
// Load the notifications from local storage
|
|
// We store the index of notifications in local storage, and the actual notification data is stored in individual entries which are encrypted
|
|
try {
|
|
const data = window.localStorage.getItem(localStorageKey) || '[]';
|
|
|
|
index = JSON.parse(data) as StoredNotification[];
|
|
} catch (e) {
|
|
console.error('Unable to read notifications from local storage', e); // eslint-disable-line no-console
|
|
}
|
|
|
|
for (let i = 0; i < index.length; i++) {
|
|
const n = index[i];
|
|
|
|
try {
|
|
const data = window.localStorage.getItem(`${ localStorageKey }-${ n.id }`);
|
|
const parsedData = data ? JSON.parse(data) : '{}';
|
|
const decryptedString = await decrypt(parsedData, encryptionKey);
|
|
const decrypted = JSON.parse(decryptedString) as EncryptedNotification;
|
|
|
|
// Overlay the decrypted data onto the notification
|
|
notifications.push({
|
|
...n,
|
|
...decrypted
|
|
});
|
|
} catch (e) {
|
|
console.error('Unable to decrypt notification data', e); // eslint-disable-line no-console
|
|
}
|
|
}
|
|
|
|
// Expire old notifications
|
|
const now = new Date();
|
|
|
|
notifications = notifications.filter((n: StoredNotification) => {
|
|
// Try ... catch in case the date parsing fails
|
|
try {
|
|
const created = new Date(n.created);
|
|
const diff = (now.getTime() - created.getTime()) / 1000; // Diff in seconds
|
|
|
|
return diff < EXPIRY;
|
|
} catch (e) {}
|
|
|
|
return true;
|
|
});
|
|
|
|
commit('load', notifications);
|
|
|
|
// Set up broadcast listener to listen for updates from other tabs
|
|
bc = new BroadcastChannel(NOTIFICATION_CHANNEL_NAME);
|
|
|
|
bc.onmessage = (msgEvent: any) => {
|
|
// Ignore events where the user id does not match (corner case of stale login in another tab)
|
|
if (msgEvent?.data?.operation && msgEvent?.data?.userId === userId) {
|
|
commit(msgEvent.data.operation, msgEvent.data.param);
|
|
}
|
|
};
|
|
}
|
|
};
|