diff --git a/.changeset/slow-shrimps-call.md b/.changeset/slow-shrimps-call.md
new file mode 100644
index 0000000000..7e04633307
--- /dev/null
+++ b/.changeset/slow-shrimps-call.md
@@ -0,0 +1,7 @@
+---
+'@backstage/plugin-notifications-backend': patch
+'@backstage/plugin-notifications-common': patch
+'@backstage/plugin-notifications': patch
+---
+
+Add support for user specific notification settings
diff --git a/.changeset/witty-bobcats-push.md b/.changeset/witty-bobcats-push.md
new file mode 100644
index 0000000000..c2b357a860
--- /dev/null
+++ b/.changeset/witty-bobcats-push.md
@@ -0,0 +1,5 @@
+---
+'@backstage/plugin-scaffolder-backend': patch
+---
+
+Add example template for notification sending
diff --git a/docs/notifications/index.md b/docs/notifications/index.md
index ddbb156dac..b44b04ef4e 100644
--- a/docs/notifications/index.md
+++ b/docs/notifications/index.md
@@ -350,6 +350,31 @@ export const myPlugin = createBackendPlugin({
});
```
+### User-specific notification settings
+
+The notifications plugin provides a way for users to manage their notification settings. To enable this, you must
+add the `UserNotificationSettingsCard` to your frontend.
+
+```tsx
+// App.tsx example
+}>
+
+
+
+
+
+
+
+```
+
+
+
+You can customize the origin names shown in the UI by passing an object where the keys are the origins and the values are the names you want to show in the UI.
+
+Each notification processor will receive its own column in the settings page, where the user can enable or disable notifications from that processor.
+
### External Services
When the emitter of a notification is a Backstage backend plugin, it is mandatory to use the integration via `@backstage/plugin-notifications-node` as described above.
diff --git a/docs/notifications/notificationSettings.png b/docs/notifications/notificationSettings.png
new file mode 100644
index 0000000000..cdad63d764
Binary files /dev/null and b/docs/notifications/notificationSettings.png differ
diff --git a/packages/app/src/App.tsx b/packages/app/src/App.tsx
index 2b0bb00929..ccf06b17bd 100644
--- a/packages/app/src/App.tsx
+++ b/packages/app/src/App.tsx
@@ -81,7 +81,10 @@ import { TwoColumnLayout } from './components/scaffolder/customScaffolderLayouts
import { customDevToolsPage } from './components/devtools/CustomDevToolsPage';
import { DevToolsPage } from '@backstage/plugin-devtools';
import { CatalogUnprocessedEntitiesPage } from '@backstage/plugin-catalog-unprocessed-entities';
-import { NotificationsPage } from '@backstage/plugin-notifications';
+import {
+ NotificationsPage,
+ UserNotificationSettingsCard,
+} from '@backstage/plugin-notifications';
const app = createApp({
apis,
@@ -206,6 +209,11 @@ const routes = (
+
+
+
}>
{customDevToolsPage}
diff --git a/packages/backend/package.json b/packages/backend/package.json
index 34de57574d..0c9ce14e1a 100644
--- a/packages/backend/package.json
+++ b/packages/backend/package.json
@@ -54,6 +54,7 @@
"@backstage/plugin-proxy-backend": "workspace:^",
"@backstage/plugin-scaffolder-backend": "workspace:^",
"@backstage/plugin-scaffolder-backend-module-github": "workspace:^",
+ "@backstage/plugin-scaffolder-backend-module-notifications": "workspace:^",
"@backstage/plugin-search-backend": "workspace:^",
"@backstage/plugin-search-backend-module-catalog": "workspace:^",
"@backstage/plugin-search-backend-module-explore": "workspace:^",
diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts
index b9130d452f..d8a4bcb98f 100644
--- a/packages/backend/src/index.ts
+++ b/packages/backend/src/index.ts
@@ -49,6 +49,9 @@ backend.add(import('@backstage/plugin-permission-backend'));
backend.add(import('@backstage/plugin-proxy-backend'));
backend.add(import('@backstage/plugin-scaffolder-backend'));
backend.add(import('@backstage/plugin-scaffolder-backend-module-github'));
+backend.add(
+ import('@backstage/plugin-scaffolder-backend-module-notifications'),
+);
backend.add(
import('@backstage/plugin-catalog-backend-module-backstage-openapi'),
);
diff --git a/plugins/notifications-backend/migrations/20240808_settings.js b/plugins/notifications-backend/migrations/20240808_settings.js
new file mode 100644
index 0000000000..4d4fbc24cd
--- /dev/null
+++ b/plugins/notifications-backend/migrations/20240808_settings.js
@@ -0,0 +1,34 @@
+/*
+ * Copyright 2024 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+exports.up = async function up(knex) {
+ await knex.schema.createTable('user_settings', table => {
+ table.string('user').notNullable();
+ table.string('channel').notNullable();
+ table.string('origin').notNullable();
+ table.boolean('enabled').defaultTo(true).notNullable();
+ table.index(['user'], 'user_settings_user_idx');
+ table.unique(['user', 'channel', 'origin'], {
+ indexName: 'user_settings_unique_idx',
+ });
+ });
+};
+
+/**
+ * @param {import('knex').Knex} knex
+ */
+exports.down = async function down(knex) {
+ await knex.schema.dropTable('user_settings');
+};
diff --git a/plugins/notifications-backend/src/database/DatabaseNotificationsStore.test.ts b/plugins/notifications-backend/src/database/DatabaseNotificationsStore.test.ts
index 0c7193c1fc..90dd3932d0 100644
--- a/plugins/notifications-backend/src/database/DatabaseNotificationsStore.test.ts
+++ b/plugins/notifications-backend/src/database/DatabaseNotificationsStore.test.ts
@@ -18,6 +18,7 @@ import { DatabaseNotificationsStore } from './DatabaseNotificationsStore';
import { Knex } from 'knex';
import {
Notification,
+ NotificationSettings,
NotificationSeverity,
} from '@backstage/plugin-notifications-common';
@@ -151,6 +152,23 @@ const otherUserNotification: Notification = {
severity: 'normal',
},
};
+const notificationSettings: NotificationSettings = {
+ channels: [
+ {
+ id: 'Web',
+ origins: [
+ {
+ id: 'plugin-test',
+ enabled: true,
+ },
+ {
+ id: 'plugin-test2',
+ enabled: false,
+ },
+ ],
+ },
+ ],
+};
describe.each(databases.eachSupportedId())(
'DatabaseNotificationsStore (%s)',
@@ -712,5 +730,18 @@ describe.each(databases.eachSupportedId())(
expect(notification?.saved).toBeNull();
});
});
+
+ describe('settings', () => {
+ it('should save and load notification settings', async () => {
+ await storage.saveNotificationSettings({
+ user: 'user:default/test',
+ settings: notificationSettings,
+ });
+ const settings = await storage.getNotificationSettings({
+ user: 'user:default/test',
+ });
+ expect(settings).toEqual(notificationSettings);
+ });
+ });
},
);
diff --git a/plugins/notifications-backend/src/database/DatabaseNotificationsStore.ts b/plugins/notifications-backend/src/database/DatabaseNotificationsStore.ts
index 40a76c7486..3a00d1c9b5 100644
--- a/plugins/notifications-backend/src/database/DatabaseNotificationsStore.ts
+++ b/plugins/notifications-backend/src/database/DatabaseNotificationsStore.ts
@@ -13,8 +13,10 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
-import { PluginDatabaseManager } from '@backstage/backend-common';
-import { resolvePackagePath } from '@backstage/backend-plugin-api';
+import {
+ DatabaseService,
+ resolvePackagePath,
+} from '@backstage/backend-plugin-api';
import {
NotificationGetOptions,
NotificationModifyOptions,
@@ -22,8 +24,9 @@ import {
} from './NotificationsStore';
import {
Notification,
- NotificationSeverity,
+ NotificationSettings,
notificationSeverities,
+ NotificationSeverity,
} from '@backstage/plugin-notifications-common';
import { Knex } from 'knex';
@@ -49,6 +52,50 @@ const NOTIFICATION_COLUMNS = [
'saved',
];
+type NotificationRowType = {
+ id: string;
+ user: string;
+ title: string;
+ description?: string | null;
+ severity: string;
+ link: string | null;
+ origin: string;
+ scope: string | null;
+ topic: string | null;
+ created: Date;
+ updated: Date | null;
+ read: Date | null;
+ saved: Date | null;
+ icon: string | null;
+};
+
+type BroadcastRowType = {
+ id: string;
+ title: string;
+ description: string | null;
+ link: string | null;
+ origin: string;
+ scope: string | null;
+ topic: string | null;
+ created: Date;
+ updated: Date | null;
+ icon: string | null;
+};
+
+type BroadcastUserStatusRowType = {
+ broadcast_id: string;
+ user: string;
+ read: Date | null;
+ saved: Date | null;
+};
+
+type UserSettingsRowType = {
+ user: string;
+ channel: string;
+ origin: string;
+ enabled: boolean;
+};
+
export const normalizeSeverity = (input?: string): NotificationSeverity => {
let lower = (input ?? 'normal').toLowerCase() as NotificationSeverity;
if (notificationSeverities.indexOf(lower) < 0) {
@@ -69,7 +116,7 @@ export class DatabaseNotificationsStore implements NotificationsStore {
database,
skipMigrations,
}: {
- database: PluginDatabaseManager;
+ database: DatabaseService;
skipMigrations?: boolean;
}): Promise {
const client = await database.getClient();
@@ -108,6 +155,29 @@ export class DatabaseNotificationsStore implements NotificationsStore {
}));
};
+ private mapToNotificationSettings = (rows: any[]): NotificationSettings => {
+ return rows.reduce(
+ (acc, row) => {
+ let chan = acc.channels.find(
+ (channel: { id: string }) => channel.id === row.channel,
+ );
+ if (!chan) {
+ acc.channels.push({
+ id: row.channel,
+ origins: [],
+ });
+ chan = acc.channels[acc.channels.length - 1];
+ }
+ chan.origins.push({
+ id: row.origin,
+ enabled: Boolean(row.enabled),
+ });
+ return acc;
+ },
+ { channels: [] },
+ );
+ };
+
private mapNotificationToDbRow = (notification: Notification) => {
return {
id: notification.id,
@@ -142,7 +212,7 @@ export class DatabaseNotificationsStore implements NotificationsStore {
};
private getBroadcastUnion = (user?: string | null) => {
- return this.db('broadcast')
+ return this.db('broadcast')
.leftJoin('broadcast_user_status', function clause() {
const join = this.on('id', '=', 'broadcast_user_status.broadcast_id');
if (user !== null && user !== undefined) {
@@ -157,7 +227,7 @@ export class DatabaseNotificationsStore implements NotificationsStore {
) => {
const { user, orderField } = options;
- const subQuery = this.db('notification')
+ const subQuery = this.db('notification')
.select(NOTIFICATION_COLUMNS)
.unionAll([this.getBroadcastUnion(user)])
.as('notifications');
@@ -295,7 +365,7 @@ export class DatabaseNotificationsStore implements NotificationsStore {
scope: string;
origin: string;
}) {
- const query = this.db('notification')
+ const query = this.db('notification')
.where('user', options.user)
.where('scope', options.scope)
.where('origin', options.origin)
@@ -305,11 +375,11 @@ export class DatabaseNotificationsStore implements NotificationsStore {
if (!rows || rows.length === 0) {
return null;
}
- return rows[0] as Notification;
+ return this.mapToNotifications(rows)[0];
}
async getExistingScopeBroadcast(options: { scope: string; origin: string }) {
- const query = this.db('broadcast')
+ const query = this.db('broadcast')
.where('scope', options.scope)
.where('origin', options.origin)
.limit(1);
@@ -318,7 +388,7 @@ export class DatabaseNotificationsStore implements NotificationsStore {
if (!rows || rows.length === 0) {
return null;
}
- return rows[0] as Notification;
+ return this.mapToNotifications(rows)[0];
}
async restoreExistingNotification({
@@ -358,7 +428,7 @@ export class DatabaseNotificationsStore implements NotificationsStore {
const rows = await this.db
.select('*')
.from(
- this.db('notification')
+ this.db('notification')
.select(NOTIFICATION_COLUMNS)
.unionAll([this.getBroadcastUnion(options.user)])
.as('notifications'),
@@ -377,7 +447,7 @@ export class DatabaseNotificationsStore implements NotificationsStore {
read?: Date | null,
saved?: Date | null,
) => {
- await this.db('notification')
+ await this.db('notification')
.whereIn('id', ids)
.where('user', user)
.update({ read, saved });
@@ -388,7 +458,7 @@ export class DatabaseNotificationsStore implements NotificationsStore {
if (broadcasts.length > 0)
if (!this.isSQLite) {
- await this.db('broadcast_user_status')
+ await this.db('broadcast_user_status')
.insert(
broadcasts.map(b => ({
broadcast_id: b.id,
@@ -402,7 +472,9 @@ export class DatabaseNotificationsStore implements NotificationsStore {
} else {
// SQLite does not support upsert so fall back to this (mostly for tests and local dev)
for (const b of broadcasts) {
- const baseQuery = this.db('broadcast_user_status')
+ const baseQuery = this.db(
+ 'broadcast_user_status',
+ )
.where('broadcast_id', b.id)
.where('user', user);
const exists = await baseQuery.clone().limit(1).select().first();
@@ -432,4 +504,63 @@ export class DatabaseNotificationsStore implements NotificationsStore {
async markUnsaved(options: NotificationModifyOptions): Promise {
await this.markReadSaved(options.ids, options.user, undefined, null);
}
+
+ async getUserNotificationOrigins(options: {
+ user: string;
+ }): Promise<{ origins: string[] }> {
+ const rows: { origin: string }[] = await this.db(
+ 'notification',
+ )
+ .where('user', options.user)
+ .select('origin')
+ .distinct();
+ return { origins: rows.map(row => row.origin) };
+ }
+
+ async getNotificationSettings(options: {
+ user: string;
+ origin?: string;
+ channel?: string;
+ }): Promise {
+ const settingsQuery = this.db('user_settings').where(
+ 'user',
+ options.user,
+ );
+ if (options.origin) {
+ settingsQuery.where('origin', options.origin);
+ }
+
+ if (options.channel) {
+ settingsQuery.where('channel', options.channel);
+ }
+ const settings = await settingsQuery.select();
+ return this.mapToNotificationSettings(settings);
+ }
+
+ async saveNotificationSettings(options: {
+ user: string;
+ settings: NotificationSettings;
+ }): Promise {
+ const rows: {
+ user: string;
+ channel: string;
+ origin: string;
+ enabled: boolean;
+ }[] = [];
+ options.settings.channels.map(channel => {
+ channel.origins.map(origin => {
+ rows.push({
+ user: options.user,
+ channel: channel.id,
+ origin: origin.id,
+ enabled: origin.enabled,
+ });
+ });
+ });
+
+ await this.db('user_settings')
+ .where('user', options.user)
+ .delete();
+ await this.db('user_settings').insert(rows);
+ }
}
diff --git a/plugins/notifications-backend/src/database/NotificationsStore.ts b/plugins/notifications-backend/src/database/NotificationsStore.ts
index 3c57d0ac4a..2560406e20 100644
--- a/plugins/notifications-backend/src/database/NotificationsStore.ts
+++ b/plugins/notifications-backend/src/database/NotificationsStore.ts
@@ -16,6 +16,7 @@
import {
Notification,
+ NotificationSettings,
NotificationSeverity,
NotificationStatus,
} from '@backstage/plugin-notifications-common';
@@ -83,4 +84,17 @@ export interface NotificationsStore {
markSaved(options: NotificationModifyOptions): Promise;
markUnsaved(options: NotificationModifyOptions): Promise;
+
+ getUserNotificationOrigins(options: {
+ user: string;
+ }): Promise<{ origins: string[] }>;
+
+ getNotificationSettings(options: {
+ user: string;
+ }): Promise;
+
+ saveNotificationSettings(options: {
+ user: string;
+ settings: NotificationSettings;
+ }): Promise;
}
diff --git a/plugins/notifications-backend/src/service/router.ts b/plugins/notifications-backend/src/service/router.ts
index 912651ab34..88cd1fd337 100644
--- a/plugins/notifications-backend/src/service/router.ts
+++ b/plugins/notifications-backend/src/service/router.ts
@@ -37,10 +37,11 @@ import {
} from '@backstage/backend-plugin-api';
import { SignalsService } from '@backstage/plugin-signals-node';
import {
+ isNotificationsEnabledFor,
NewNotificationSignal,
Notification,
- NotificationPayload,
NotificationReadSignal,
+ NotificationSettings,
notificationSeverities,
NotificationStatus,
} from '@backstage/plugin-notifications-common';
@@ -77,6 +78,7 @@ export async function createRouter(
signals,
} = options;
+ const WEB_NOTIFICATION_CHANNEL = 'Web';
const store = await DatabaseNotificationsStore.create({ database });
const frontendBaseUrl = config.getString('app.baseUrl');
@@ -86,10 +88,62 @@ export async function createRouter(
return info.userEntityRef;
};
- const filterProcessors = (payload: NotificationPayload) => {
+ const getNotificationChannels = () => {
+ return [WEB_NOTIFICATION_CHANNEL, ...processors.map(p => p.getName())];
+ };
+
+ const getNotificationSettings = async (user: string) => {
+ const { origins } = await store.getUserNotificationOrigins({ user });
+ const settings = await store.getNotificationSettings({ user });
+ const channels = getNotificationChannels();
+
+ const response: NotificationSettings = {
+ channels: channels.map(channel => {
+ const channelSettings = settings.channels.find(c => c.id === channel);
+ if (channelSettings) {
+ return channelSettings;
+ }
+ return {
+ id: channel,
+ origins: origins.map(origin => ({
+ id: origin,
+ enabled: true,
+ })),
+ };
+ }),
+ };
+ return response;
+ };
+
+ const isNotificationsEnabled = async (opts: {
+ user: string;
+ channel: string;
+ origin: string;
+ }) => {
+ const settings = await getNotificationSettings(opts.user);
+ return isNotificationsEnabledFor(settings, opts.channel, opts.origin);
+ };
+
+ const filterProcessors = async (
+ notification:
+ | Notification
+ | ({ origin: string; user: null } & NotificationSendOptions),
+ ) => {
const result: NotificationProcessor[] = [];
+ const { payload, user, origin } = notification;
for (const processor of processors) {
+ if (user) {
+ const enabled = await isNotificationsEnabled({
+ user,
+ origin,
+ channel: processor.getName(),
+ });
+ if (!enabled) {
+ continue;
+ }
+ }
+
if (processor.getNotificationFilters) {
const filters = processor.getNotificationFilters();
if (filters.minSeverity) {
@@ -122,8 +176,11 @@ export async function createRouter(
return result;
};
- const processOptions = async (opts: NotificationSendOptions) => {
- const filtered = filterProcessors(opts.payload);
+ const processOptions = async (
+ opts: NotificationSendOptions,
+ origin: string,
+ ) => {
+ const filtered = await filterProcessors({ ...opts, origin, user: null });
let ret = opts;
for (const processor of filtered) {
try {
@@ -143,7 +200,7 @@ export async function createRouter(
notification: Notification,
opts: NotificationSendOptions,
) => {
- const filtered = filterProcessors(notification.payload);
+ const filtered = await filterProcessors(notification);
let ret = notification;
for (const processor of filtered) {
try {
@@ -163,7 +220,7 @@ export async function createRouter(
notification: Notification,
opts: NotificationSendOptions,
) => {
- const filtered = filterProcessors(notification.payload);
+ const filtered = await filterProcessors(notification);
for (const processor of filtered) {
if (processor.postProcess) {
try {
@@ -261,6 +318,33 @@ export async function createRouter(
res.json(status);
});
+ router.get(
+ '/settings',
+ async (req: Request, res) => {
+ const user = await getUser(req);
+ const response = await getNotificationSettings(user);
+ res.json(response);
+ },
+ );
+
+ router.post(
+ '/settings',
+ async (
+ req: Request,
+ res,
+ ) => {
+ const user = await getUser(req);
+ const channels = getNotificationChannels();
+ const settings: NotificationSettings = req.body;
+ if (settings.channels.some(c => !channels.includes(c.id))) {
+ throw new InputError('Invalid channel');
+ }
+ await store.saveNotificationSettings({ user, settings });
+ const response = await getNotificationSettings(user);
+ res.json(response);
+ },
+ );
+
// Make sure this is the last "GET" handler
router.get('/:id', async (req, res) => {
const user = await getUser(req);
@@ -380,37 +464,45 @@ export async function createRouter(
};
const notification = await preProcessNotification(userNotification, opts);
- let existingNotification;
- if (scope) {
- existingNotification = await store.getExistingScopeNotification({
- user,
- scope,
- origin,
- });
- }
+ const enabled = await isNotificationsEnabled({
+ user,
+ channel: WEB_NOTIFICATION_CHANNEL,
+ origin: userNotification.origin,
+ });
let ret = notification;
- if (existingNotification) {
- const restored = await store.restoreExistingNotification({
- id: existingNotification.id,
- notification,
- });
- ret = restored ?? notification;
- } else {
- await store.saveNotification(notification);
- }
+ if (enabled) {
+ let existingNotification;
+ if (scope) {
+ existingNotification = await store.getExistingScopeNotification({
+ user,
+ scope,
+ origin,
+ });
+ }
- notifications.push(ret);
+ if (existingNotification) {
+ const restored = await store.restoreExistingNotification({
+ id: existingNotification.id,
+ notification,
+ });
+ ret = restored ?? notification;
+ } else {
+ await store.saveNotification(notification);
+ }
- if (signals) {
- await signals.publish({
- recipients: { type: 'user', entityRef: [user] },
- message: {
- action: 'new_notification',
- notification_id: ret.id,
- },
- channel: 'notifications',
- });
+ notifications.push(ret);
+
+ if (signals) {
+ await signals.publish({
+ recipients: { type: 'user', entityRef: [user] },
+ message: {
+ action: 'new_notification',
+ notification_id: ret.id,
+ },
+ channel: 'notifications',
+ });
+ }
}
postProcessNotification(ret, opts);
}
@@ -421,16 +513,16 @@ export async function createRouter(
router.post(
'/',
async (req: Request, res) => {
- const opts = await processOptions(req.body);
- const { recipients, payload } = opts;
- const notifications: Notification[] = [];
- let users = [];
-
const credentials = await httpAuth.credentials(req, {
allow: ['service'],
});
+ const origin = credentials.principal.subject;
+ const opts = await processOptions(req.body, origin);
+ const { recipients, payload } = opts;
const { title, link } = payload;
+ const notifications: Notification[] = [];
+ let users = [];
if (!recipients || !title) {
logger.error(`Invalid notification request received`);
@@ -445,7 +537,6 @@ export async function createRouter(
}
}
- const origin = credentials.principal.subject;
const baseNotification = {
payload: {
...payload,
diff --git a/plugins/notifications-common/report.api.md b/plugins/notifications-common/report.api.md
index 0f59a85849..167b37349d 100644
--- a/plugins/notifications-common/report.api.md
+++ b/plugins/notifications-common/report.api.md
@@ -10,6 +10,13 @@ export const getProcessorFiltersFromConfig: (
config: Config,
) => NotificationProcessorFilters;
+// @public (undocumented)
+export const isNotificationsEnabledFor: (
+ settings: NotificationSettings,
+ channelId: string,
+ originId: string,
+) => boolean;
+
// @public (undocumented)
export type NewNotificationSignal = {
action: 'new_notification';
@@ -53,6 +60,17 @@ export type NotificationReadSignal = {
notification_ids: string[];
};
+// @public (undocumented)
+export type NotificationSettings = {
+ channels: {
+ id: string;
+ origins: {
+ id: string;
+ enabled: boolean;
+ }[];
+ }[];
+};
+
// @public
export const notificationSeverities: NotificationSeverity[];
diff --git a/plugins/notifications-common/src/index.ts b/plugins/notifications-common/src/index.ts
index 9fc7bde2b8..b1036b5da7 100644
--- a/plugins/notifications-common/src/index.ts
+++ b/plugins/notifications-common/src/index.ts
@@ -23,3 +23,4 @@
export * from './types';
export * from './constants';
export * from './filters';
+export * from './utils';
diff --git a/plugins/notifications-common/src/types.ts b/plugins/notifications-common/src/types.ts
index a4be3e4255..237f93f40a 100644
--- a/plugins/notifications-common/src/types.ts
+++ b/plugins/notifications-common/src/types.ts
@@ -125,3 +125,16 @@ export type NotificationProcessorFilters = {
maxSeverity?: NotificationSeverity;
excludedTopics?: string[];
};
+
+/**
+ * @public
+ */
+export type NotificationSettings = {
+ channels: {
+ id: string;
+ origins: {
+ id: string;
+ enabled: boolean;
+ }[];
+ }[];
+};
diff --git a/plugins/notifications-common/src/utils.ts b/plugins/notifications-common/src/utils.ts
new file mode 100644
index 0000000000..c57412bd97
--- /dev/null
+++ b/plugins/notifications-common/src/utils.ts
@@ -0,0 +1,34 @@
+/*
+ * Copyright 2024 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import { NotificationSettings } from './types';
+
+/** @public */
+export const isNotificationsEnabledFor = (
+ settings: NotificationSettings,
+ channelId: string,
+ originId: string,
+) => {
+ const channel = settings.channels.find(c => c.id === channelId);
+ if (!channel) {
+ return true;
+ }
+
+ const origin = channel.origins.find(o => o.id === originId);
+ if (!origin) {
+ return true;
+ }
+ return origin.enabled;
+};
diff --git a/plugins/notifications/report.api.md b/plugins/notifications/report.api.md
index 5e06ae2616..2f2357e479 100644
--- a/plugins/notifications/report.api.md
+++ b/plugins/notifications/report.api.md
@@ -12,6 +12,7 @@ import { FetchApi } from '@backstage/core-plugin-api';
import { IconComponent } from '@backstage/core-plugin-api';
import { JSX as JSX_2 } from 'react';
import { Notification as Notification_2 } from '@backstage/plugin-notifications-common';
+import { NotificationSettings } from '@backstage/plugin-notifications-common';
import { NotificationSeverity } from '@backstage/plugin-notifications-common';
import { NotificationStatus } from '@backstage/plugin-notifications-common';
import { default as React_2 } from 'react';
@@ -46,11 +47,17 @@ export interface NotificationsApi {
options?: GetNotificationsOptions,
): Promise;
// (undocumented)
+ getNotificationSettings(): Promise;
+ // (undocumented)
getStatus(): Promise;
// (undocumented)
updateNotifications(
options: UpdateNotificationsOptions,
): Promise;
+ // (undocumented)
+ updateNotificationSettings(
+ settings: NotificationSettings,
+ ): Promise;
}
// @public (undocumented)
@@ -66,11 +73,17 @@ export class NotificationsClient implements NotificationsApi {
options?: GetNotificationsOptions,
): Promise;
// (undocumented)
+ getNotificationSettings(): Promise;
+ // (undocumented)
getStatus(): Promise;
// (undocumented)
updateNotifications(
options: UpdateNotificationsOptions,
): Promise;
+ // (undocumented)
+ updateNotificationSettings(
+ settings: NotificationSettings,
+ ): Promise;
}
// @public (undocumented)
@@ -176,5 +189,10 @@ export function useNotificationsApi(
value: T;
};
+// @public (undocumented)
+export const UserNotificationSettingsCard: (props: {
+ originNames?: Record;
+}) => React_2.JSX.Element;
+
// (No @packageDocumentation comment for this package)
```
diff --git a/plugins/notifications/src/api/NotificationsApi.ts b/plugins/notifications/src/api/NotificationsApi.ts
index 234e86ff13..e24e5cad64 100644
--- a/plugins/notifications/src/api/NotificationsApi.ts
+++ b/plugins/notifications/src/api/NotificationsApi.ts
@@ -16,6 +16,7 @@
import { createApiRef } from '@backstage/core-plugin-api';
import {
Notification,
+ NotificationSettings,
NotificationSeverity,
NotificationStatus,
} from '@backstage/plugin-notifications-common';
@@ -64,4 +65,10 @@ export interface NotificationsApi {
updateNotifications(
options: UpdateNotificationsOptions,
): Promise;
+
+ getNotificationSettings(): Promise;
+
+ updateNotificationSettings(
+ settings: NotificationSettings,
+ ): Promise;
}
diff --git a/plugins/notifications/src/api/NotificationsClient.ts b/plugins/notifications/src/api/NotificationsClient.ts
index 10ad58bd92..983065f96e 100644
--- a/plugins/notifications/src/api/NotificationsClient.ts
+++ b/plugins/notifications/src/api/NotificationsClient.ts
@@ -23,6 +23,7 @@ import { DiscoveryApi, FetchApi } from '@backstage/core-plugin-api';
import { ResponseError } from '@backstage/errors';
import {
Notification,
+ NotificationSettings,
NotificationStatus,
} from '@backstage/plugin-notifications-common';
@@ -93,6 +94,20 @@ export class NotificationsClient implements NotificationsApi {
});
}
+ async getNotificationSettings(): Promise {
+ return await this.request('settings');
+ }
+
+ async updateNotificationSettings(
+ settings: NotificationSettings,
+ ): Promise {
+ return await this.request('settings', {
+ method: 'POST',
+ body: JSON.stringify(settings),
+ headers: { 'Content-Type': 'application/json' },
+ });
+ }
+
private async request(path: string, init?: any): Promise {
const baseUrl = `${await this.discoveryApi.getBaseUrl('notifications')}/`;
const url = new URL(path, baseUrl);
diff --git a/plugins/notifications/src/components/UserNotificationSettingsCard/UserNotificationSettingsCard.tsx b/plugins/notifications/src/components/UserNotificationSettingsCard/UserNotificationSettingsCard.tsx
new file mode 100644
index 0000000000..81d109e5cc
--- /dev/null
+++ b/plugins/notifications/src/components/UserNotificationSettingsCard/UserNotificationSettingsCard.tsx
@@ -0,0 +1,63 @@
+/*
+ * Copyright 2024 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import React, { useEffect } from 'react';
+import { ErrorPanel, InfoCard, Progress } from '@backstage/core-components';
+import { useNotificationsApi } from '../../hooks';
+import { NotificationSettings } from '@backstage/plugin-notifications-common';
+import { notificationsApiRef } from '../../api';
+import { useApi } from '@backstage/core-plugin-api';
+import { UserNotificationSettingsPanel } from './UserNotificationSettingsPanel';
+
+/** @public */
+export const UserNotificationSettingsCard = (props: {
+ originNames?: Record;
+}) => {
+ const [settings, setNotificationSettings] = React.useState<
+ NotificationSettings | undefined
+ >(undefined);
+
+ const client = useApi(notificationsApiRef);
+ const { error, value, loading } = useNotificationsApi(api => {
+ return api.getNotificationSettings();
+ });
+
+ useEffect(() => {
+ if (!loading && !error) {
+ setNotificationSettings(value);
+ }
+ }, [loading, value, error]);
+
+ const onUpdate = (newSettings: NotificationSettings) => {
+ client
+ .updateNotificationSettings(newSettings)
+ .then(updatedSettings => setNotificationSettings(updatedSettings));
+ };
+
+ return (
+
+ {loading && }
+ {error && }
+ {settings && (
+
+ )}
+
+ );
+};
diff --git a/plugins/notifications/src/components/UserNotificationSettingsCard/UserNotificationSettingsPanel.tsx b/plugins/notifications/src/components/UserNotificationSettingsCard/UserNotificationSettingsPanel.tsx
new file mode 100644
index 0000000000..dc9f2a223f
--- /dev/null
+++ b/plugins/notifications/src/components/UserNotificationSettingsCard/UserNotificationSettingsPanel.tsx
@@ -0,0 +1,140 @@
+/*
+ * Copyright 2024 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import React from 'react';
+import {
+ isNotificationsEnabledFor,
+ NotificationSettings,
+} from '@backstage/plugin-notifications-common';
+import Table from '@material-ui/core/Table';
+import MuiTableCell from '@material-ui/core/TableCell';
+import { withStyles } from '@material-ui/core/styles';
+import TableHead from '@material-ui/core/TableHead';
+import Typography from '@material-ui/core/Typography';
+import TableBody from '@material-ui/core/TableBody';
+import TableRow from '@material-ui/core/TableRow';
+import Switch from '@material-ui/core/Switch';
+import { capitalize } from 'lodash';
+import Tooltip from '@material-ui/core/Tooltip';
+
+const TableCell = withStyles({
+ root: {
+ borderBottom: 'none',
+ },
+})(MuiTableCell);
+
+export const UserNotificationSettingsPanel = (props: {
+ settings: NotificationSettings;
+ onChange: (settings: NotificationSettings) => void;
+ originNames?: Record;
+}) => {
+ const { settings, onChange } = props;
+ const allOrigins = [
+ ...new Set(
+ settings.channels.flatMap(channel =>
+ channel.origins.map(origin => origin.id),
+ ),
+ ),
+ ];
+
+ const handleChange = (
+ channelId: string,
+ originId: string,
+ enabled: boolean,
+ ) => {
+ const updatedSettings = {
+ channels: settings.channels.map(channel => {
+ if (channel.id !== channelId) {
+ return channel;
+ }
+ return {
+ ...channel,
+ origins: channel.origins.map(origin => {
+ if (origin.id !== originId) {
+ return origin;
+ }
+ return {
+ ...origin,
+ enabled,
+ };
+ }),
+ };
+ }),
+ };
+ onChange(updatedSettings);
+ };
+
+ const formatOriginName = (originId: string) => {
+ if (props.originNames && originId in props.originNames) {
+ return props.originNames[originId];
+ }
+ return capitalize(originId.replaceAll(/[_:]/g, ' '));
+ };
+
+ if (settings.channels.length === 0 || allOrigins.length === 0) {
+ return (
+
+ No notification settings available, check back later
+
+ );
+ }
+
+ return (
+
+
+
+
+ Origin
+
+ {settings.channels.map(channel => (
+
+ {channel.id}
+
+ ))}
+
+
+
+ {allOrigins.map(origin => (
+
+ {formatOriginName(origin)}
+ {settings.channels.map(channel => (
+
+
+ ) => {
+ handleChange(channel.id, origin, event.target.checked);
+ }}
+ />
+
+
+ ))}
+
+ ))}
+
+
+ );
+};
diff --git a/plugins/notifications/src/components/UserNotificationSettingsCard/index.ts b/plugins/notifications/src/components/UserNotificationSettingsCard/index.ts
new file mode 100644
index 0000000000..98265b0be1
--- /dev/null
+++ b/plugins/notifications/src/components/UserNotificationSettingsCard/index.ts
@@ -0,0 +1,16 @@
+/*
+ * Copyright 2024 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+export { UserNotificationSettingsCard } from './UserNotificationSettingsCard';
diff --git a/plugins/notifications/src/components/index.ts b/plugins/notifications/src/components/index.ts
index 022ae07198..8aefb8835b 100644
--- a/plugins/notifications/src/components/index.ts
+++ b/plugins/notifications/src/components/index.ts
@@ -15,4 +15,5 @@
*/
export * from './NotificationsSideBarItem';
export * from './NotificationsTable';
+export * from './UserNotificationSettingsCard';
export type { NotificationsPageProps } from './NotificationsPage';
diff --git a/plugins/scaffolder-backend/sample-templates/all-templates.yaml b/plugins/scaffolder-backend/sample-templates/all-templates.yaml
index 6637c9479b..1e1fb5baf1 100644
--- a/plugins/scaffolder-backend/sample-templates/all-templates.yaml
+++ b/plugins/scaffolder-backend/sample-templates/all-templates.yaml
@@ -6,6 +6,7 @@ metadata:
spec:
targets:
- ./remote-templates.yaml
+ - ./notifications-demo/template.yaml
# For local development of a template, you can reference your local templates here.
# Examples:
#
diff --git a/plugins/scaffolder-backend/sample-templates/notifications-demo/template.yaml b/plugins/scaffolder-backend/sample-templates/notifications-demo/template.yaml
new file mode 100644
index 0000000000..a85f53b12f
--- /dev/null
+++ b/plugins/scaffolder-backend/sample-templates/notifications-demo/template.yaml
@@ -0,0 +1,69 @@
+apiVersion: scaffolder.backstage.io/v1beta3
+kind: Template
+metadata:
+ name: notifications-demo
+ title: Test Notifications template
+ description: scaffolder v1beta3 template demo sending notification
+spec:
+ owner: backstage/techdocs-core
+ type: service
+ parameters:
+ - title: Notification
+ required:
+ - recipients
+ - title
+ properties:
+ recipients:
+ title: Recipients
+ type: string
+ description: Notification recipients
+ default: entity
+ enum:
+ - entity
+ - broadcast
+ entityRefs:
+ title: Entities
+ type: array
+ description: Entities to send the notification. Required if recipients is entity
+ ui:field: MultiEntityPicker
+ ui:options:
+ defaultNamespace: default
+ title:
+ title: Title
+ type: string
+ description: Notification title
+ description:
+ title: Description
+ type: string
+ description: Notification longer description
+ link:
+ title: Link
+ type: string
+ description: Notification link
+ severity:
+ title: Severity
+ type: string
+ description: Notification severity
+ default: normal
+ enum:
+ - low
+ - normal
+ - high
+ - critical
+ scope:
+ title: Scope
+ type: string
+ description: Notification scope
+
+ steps:
+ - id: send-notification
+ name: Send notification
+ action: notification:send
+ input:
+ recipients: ${{ parameters.recipients }}
+ entityRefs: ${{ parameters.entityRefs }}
+ title: ${{ parameters.title }}
+ description: ${{ parameters.description }}
+ link: ${{ parameters.link }}
+ severity: ${{ parameters.severity }}
+ scope: ${{ parameters.scope }}
diff --git a/yarn.lock b/yarn.lock
index af909aa8c1..4d105cc198 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -7212,7 +7212,7 @@ __metadata:
languageName: unknown
linkType: soft
-"@backstage/plugin-scaffolder-backend-module-notifications@workspace:plugins/scaffolder-backend-module-notifications":
+"@backstage/plugin-scaffolder-backend-module-notifications@workspace:^, @backstage/plugin-scaffolder-backend-module-notifications@workspace:plugins/scaffolder-backend-module-notifications":
version: 0.0.0-use.local
resolution: "@backstage/plugin-scaffolder-backend-module-notifications@workspace:plugins/scaffolder-backend-module-notifications"
dependencies:
@@ -27080,6 +27080,7 @@ __metadata:
"@backstage/plugin-proxy-backend": "workspace:^"
"@backstage/plugin-scaffolder-backend": "workspace:^"
"@backstage/plugin-scaffolder-backend-module-github": "workspace:^"
+ "@backstage/plugin-scaffolder-backend-module-notifications": "workspace:^"
"@backstage/plugin-search-backend": "workspace:^"
"@backstage/plugin-search-backend-module-catalog": "workspace:^"
"@backstage/plugin-search-backend-module-explore": "workspace:^"