feat: allow users to select notification types (#1512)

* feat: allow users to select notification types

* fix(ui): display personal notification types before management types

* fix: update allRequestsAutoApproved check to account for new REQUEST_MOVIE & REQUEST_TV perms

* fix(ui): do not display Discord notif type selector if user not eligible for any types

* refactor(ui): remove unnecessary 'enabled' checkboxes from user notif settings

* fix(ui): correct checkbox behavior

* fix: add missing return type on hasNotificationType

* refactor: remove unused isValid prop in NotificationsWebPush

* fix(ui): use SensitiveInput for users' public PGP keys

* fix(ui): add missing tip/hint for email encryption setting

* refactor(svg): use the new Discord logo

* revert(api): undo breaking change removing discordEnabled from UserSettingsNotificationsResponse

* fix(lang): update notification type descriptions for clarity

* fix(telegram): do not send users notifications of their own auto-approved requests
pull/1684/head
TheCatLady 3 years ago committed by GitHub
parent 6a3649f620
commit e60598905b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -1213,8 +1213,6 @@ components:
type: string
userToken:
type: string
priority:
type: number
LunaSeaSettings:
type: object
properties:
@ -1599,6 +1597,9 @@ components:
nullable: true
discordEnabled:
type: boolean
discordEnabledTypes:
type: number
nullable: true
discordId:
type: string
nullable: true

@ -108,7 +108,10 @@ export class UserSettings {
})
public notificationTypes: Partial<NotificationAgentTypes>;
public hasNotificationType(key: NotificationAgentKey, type: Notification) {
public hasNotificationType(
key: NotificationAgentKey,
type: Notification
): boolean {
return hasNotificationType(type, this.notificationTypes[key] ?? 0);
}
}

@ -20,6 +20,7 @@ export interface UserSettingsNotificationsResponse {
emailEnabled?: boolean;
pgpKey?: string;
discordEnabled?: boolean;
discordEnabledTypes?: number;
discordId?: string;
telegramEnabled?: boolean;
telegramBotUsername?: string;

@ -24,6 +24,6 @@ export abstract class BaseAgent<T extends NotificationAgentConfig> {
}
export interface NotificationAgent {
shouldSend(type: Notification): boolean;
shouldSend(): boolean;
send(type: Notification, payload: NotificationPayload): Promise<boolean>;
}

@ -193,12 +193,10 @@ class DiscordAgent
};
}
public shouldSend(type: Notification): boolean {
if (
this.getSettings().enabled &&
this.getSettings().options.webhookUrl &&
hasNotificationType(type, this.getSettings().types)
) {
public shouldSend(): boolean {
const settings = this.getSettings();
if (settings.enabled && settings.options.webhookUrl) {
return true;
}
@ -209,6 +207,12 @@ class DiscordAgent
type: Notification,
payload: NotificationPayload
): Promise<boolean> {
const settings = this.getSettings();
if (!hasNotificationType(type, settings.types ?? 0)) {
return true;
}
logger.debug('Sending Discord notification', {
label: 'Notifications',
type: Notification[type],
@ -218,13 +222,6 @@ class DiscordAgent
let content = undefined;
try {
const { botUsername, botAvatarUrl, webhookUrl } =
this.getSettings().options;
if (!webhookUrl) {
return false;
}
if (payload.notifyUser) {
// Mention user who submitted the request
if (
@ -258,9 +255,9 @@ class DiscordAgent
.join(' ');
}
await axios.post(webhookUrl, {
username: botUsername,
avatar_url: botAvatarUrl,
await axios.post(settings.options.webhookUrl, {
username: settings.options.botUsername,
avatar_url: settings.options.botAvatarUrl,
embeds: [this.buildEmbed(type, payload)],
content,
} as DiscordWebhookPayload);

@ -1,7 +1,7 @@
import { EmailOptions } from 'email-templates';
import path from 'path';
import { getRepository } from 'typeorm';
import { hasNotificationType, Notification } from '..';
import { Notification } from '..';
import { MediaType } from '../../../constants/media';
import { User } from '../../../entity/User';
import logger from '../../../logger';
@ -28,12 +28,14 @@ class EmailAgent
return settings.notifications.agents.email;
}
public shouldSend(type: Notification): boolean {
public shouldSend(): boolean {
const settings = this.getSettings();
if (
settings.enabled &&
hasNotificationType(type, this.getSettings().types)
settings.options.emailFrom &&
settings.options.smtpHost &&
settings.options.smtpPort
) {
return true;
}

@ -50,12 +50,10 @@ class LunaSeaAgent
};
}
public shouldSend(type: Notification): boolean {
if (
this.getSettings().enabled &&
this.getSettings().options.webhookUrl &&
hasNotificationType(type, this.getSettings().types)
) {
public shouldSend(): boolean {
const settings = this.getSettings();
if (settings.enabled && settings.options.webhookUrl) {
return true;
}
@ -66,6 +64,12 @@ class LunaSeaAgent
type: Notification,
payload: NotificationPayload
): Promise<boolean> {
const settings = this.getSettings();
if (!hasNotificationType(type, settings.types ?? 0)) {
return true;
}
logger.debug('Sending LunaSea notification', {
label: 'Notifications',
type: Notification[type],
@ -73,19 +77,19 @@ class LunaSeaAgent
});
try {
const { webhookUrl, profileName } = this.getSettings().options;
if (!webhookUrl) {
return false;
}
await axios.post(webhookUrl, this.buildPayload(type, payload), {
headers: {
Authorization: `Basic ${Buffer.from(`${profileName}:`).toString(
'base64'
)}`,
},
});
await axios.post(
settings.options.webhookUrl,
this.buildPayload(type, payload),
settings.options.profileName
? {
headers: {
Authorization: `Basic ${Buffer.from(
`${settings.options.profileName}:`
).toString('base64')}`,
},
}
: undefined
);
return true;
} catch (e) {
@ -94,7 +98,7 @@ class LunaSeaAgent
type: Notification[type],
subject: payload.subject,
errorMessage: e.message,
response: e.response.data,
response: e.response?.data,
});
return false;

@ -24,12 +24,10 @@ class PushbulletAgent
return settings.notifications.agents.pushbullet;
}
public shouldSend(type: Notification): boolean {
if (
this.getSettings().enabled &&
this.getSettings().options.accessToken &&
hasNotificationType(type, this.getSettings().types)
) {
public shouldSend(): boolean {
const settings = this.getSettings();
if (settings.enabled && settings.options.accessToken) {
return true;
}
@ -137,6 +135,12 @@ class PushbulletAgent
type: Notification,
payload: NotificationPayload
): Promise<boolean> {
const settings = this.getSettings();
if (!hasNotificationType(type, settings.types ?? 0)) {
return true;
}
logger.debug('Sending Pushbullet notification', {
label: 'Notifications',
type: Notification[type],
@ -144,14 +148,10 @@ class PushbulletAgent
});
try {
const endpoint = 'https://api.pushbullet.com/v2/pushes';
const { accessToken } = this.getSettings().options;
const { title, body } = this.constructMessageDetails(type, payload);
await axios.post(
endpoint,
'https://api.pushbullet.com/v2/pushes',
{
type: 'note',
title: title,
@ -159,7 +159,7 @@ class PushbulletAgent
} as PushbulletPayload,
{
headers: {
'Access-Token': accessToken,
'Access-Token': settings.options.accessToken,
},
}
);

@ -30,12 +30,13 @@ class PushoverAgent
return settings.notifications.agents.pushover;
}
public shouldSend(type: Notification): boolean {
public shouldSend(): boolean {
const settings = this.getSettings();
if (
this.getSettings().enabled &&
this.getSettings().options.accessToken &&
this.getSettings().options.userToken &&
hasNotificationType(type, this.getSettings().types)
settings.enabled &&
settings.options.accessToken &&
settings.options.userToken
) {
return true;
}
@ -161,6 +162,12 @@ class PushoverAgent
type: Notification,
payload: NotificationPayload
): Promise<boolean> {
const settings = this.getSettings();
if (!hasNotificationType(type, settings.types ?? 0)) {
return true;
}
logger.debug('Sending Pushover notification', {
label: 'Notifications',
type: Notification[type],
@ -169,14 +176,12 @@ class PushoverAgent
try {
const endpoint = 'https://api.pushover.net/1/messages.json';
const { accessToken, userToken } = this.getSettings().options;
const { title, message, url, url_title, priority } =
this.constructMessageDetails(type, payload);
await axios.post(endpoint, {
token: accessToken,
user: userToken,
token: settings.options.accessToken,
user: settings.options.userToken,
title: title,
message: message,
url: url,

@ -218,12 +218,10 @@ class SlackAgent
};
}
public shouldSend(type: Notification): boolean {
if (
this.getSettings().enabled &&
this.getSettings().options.webhookUrl &&
hasNotificationType(type, this.getSettings().types)
) {
public shouldSend(): boolean {
const settings = this.getSettings();
if (settings.enabled && settings.options.webhookUrl) {
return true;
}
@ -234,19 +232,22 @@ class SlackAgent
type: Notification,
payload: NotificationPayload
): Promise<boolean> {
const settings = this.getSettings();
if (!hasNotificationType(type, settings.types ?? 0)) {
return true;
}
logger.debug('Sending Slack notification', {
label: 'Notifications',
type: Notification[type],
subject: payload.subject,
});
try {
const webhookUrl = this.getSettings().options.webhookUrl;
if (!webhookUrl) {
return false;
}
await axios.post(webhookUrl, this.buildEmbed(type, payload));
await axios.post(
settings.options.webhookUrl,
this.buildEmbed(type, payload)
);
return true;
} catch (e) {

@ -1,7 +1,10 @@
import axios from 'axios';
import { getRepository } from 'typeorm';
import { hasNotificationType, Notification } from '..';
import { MediaType } from '../../../constants/media';
import { User } from '../../../entity/User';
import logger from '../../../logger';
import { Permission } from '../../permissions';
import {
getSettings,
NotificationAgentKey,
@ -40,12 +43,13 @@ class TelegramAgent
return settings.notifications.agents.telegram;
}
public shouldSend(type: Notification): boolean {
public shouldSend(): boolean {
const settings = this.getSettings();
if (
this.getSettings().enabled &&
this.getSettings().options.botAPI &&
this.getSettings().options.chatId &&
hasNotificationType(type, this.getSettings().types)
settings.enabled &&
settings.options.botAPI &&
settings.options.chatId
) {
return true;
}
@ -59,8 +63,10 @@ class TelegramAgent
private buildMessage(
type: Notification,
payload: NotificationPayload
): string {
payload: NotificationPayload,
chatId: string,
sendSilently: boolean
): TelegramMessagePayload | TelegramPhotoPayload {
const settings = getSettings();
let message = '';
@ -153,67 +159,36 @@ class TelegramAgent
}
/* eslint-enable */
return message;
return payload.image
? ({
photo: payload.image,
caption: message,
parse_mode: 'MarkdownV2',
chat_id: chatId,
disable_notification: !!sendSilently,
} as TelegramPhotoPayload)
: ({
text: message,
parse_mode: 'MarkdownV2',
chat_id: chatId,
disable_notification: !!sendSilently,
} as TelegramMessagePayload);
}
public async send(
type: Notification,
payload: NotificationPayload
): Promise<boolean> {
const endpoint = `${this.baseUrl}bot${this.getSettings().options.botAPI}/${
const settings = this.getSettings();
const endpoint = `${this.baseUrl}bot${settings.options.botAPI}/${
payload.image ? 'sendPhoto' : 'sendMessage'
}`;
// Send system notification
try {
logger.debug('Sending Telegram notification', {
label: 'Notifications',
type: Notification[type],
subject: payload.subject,
});
await axios.post(
endpoint,
payload.image
? ({
photo: payload.image,
caption: this.buildMessage(type, payload),
parse_mode: 'MarkdownV2',
chat_id: this.getSettings().options.chatId,
disable_notification: this.getSettings().options.sendSilently,
} as TelegramPhotoPayload)
: ({
text: this.buildMessage(type, payload),
parse_mode: 'MarkdownV2',
chat_id: `${this.getSettings().options.chatId}`,
disable_notification: this.getSettings().options.sendSilently,
} as TelegramMessagePayload)
);
} catch (e) {
logger.error('Error sending Telegram notification', {
label: 'Notifications',
type: Notification[type],
subject: payload.subject,
errorMessage: e.message,
response: e.response.data,
});
return false;
}
if (
payload.notifyUser &&
payload.notifyUser.settings?.hasNotificationType(
NotificationAgentKey.TELEGRAM,
type
) &&
payload.notifyUser.settings?.telegramChatId &&
payload.notifyUser.settings?.telegramChatId !==
this.getSettings().options.chatId
) {
// Send notification to the user who submitted the request
if (hasNotificationType(type, settings.types ?? 0)) {
logger.debug('Sending Telegram notification', {
label: 'Notifications',
recipient: payload.notifyUser.displayName,
type: Notification[type],
subject: payload.subject,
});
@ -221,27 +196,16 @@ class TelegramAgent
try {
await axios.post(
endpoint,
payload.image
? ({
photo: payload.image,
caption: this.buildMessage(type, payload),
parse_mode: 'MarkdownV2',
chat_id: payload.notifyUser.settings.telegramChatId,
disable_notification:
payload.notifyUser.settings.telegramSendSilently,
} as TelegramPhotoPayload)
: ({
text: this.buildMessage(type, payload),
parse_mode: 'MarkdownV2',
chat_id: payload.notifyUser.settings.telegramChatId,
disable_notification:
payload.notifyUser.settings.telegramSendSilently,
} as TelegramMessagePayload)
this.buildMessage(
type,
payload,
settings.options.chatId,
settings.options.sendSilently
)
);
} catch (e) {
logger.error('Error sending Telegram notification', {
label: 'Notifications',
recipient: payload.notifyUser.displayName,
type: Notification[type],
subject: payload.subject,
errorMessage: e.message,
@ -252,6 +216,103 @@ class TelegramAgent
}
}
if (payload.notifyUser) {
// Send notification to the user who submitted the request
if (
payload.notifyUser.settings?.hasNotificationType(
NotificationAgentKey.TELEGRAM,
type
) &&
payload.notifyUser.settings?.telegramChatId &&
payload.notifyUser.settings?.telegramChatId !== settings.options.chatId
) {
logger.debug('Sending Telegram notification', {
label: 'Notifications',
recipient: payload.notifyUser.displayName,
type: Notification[type],
subject: payload.subject,
});
try {
await axios.post(
endpoint,
this.buildMessage(
type,
payload,
payload.notifyUser.settings.telegramChatId,
!!payload.notifyUser.settings.telegramSendSilently
)
);
} catch (e) {
logger.error('Error sending Telegram notification', {
label: 'Notifications',
recipient: payload.notifyUser.displayName,
type: Notification[type],
subject: payload.subject,
errorMessage: e.message,
response: e.response?.data,
});
return false;
}
}
} else {
// Send notifications to all users with the Manage Requests permission
const userRepository = getRepository(User);
const users = await userRepository.find();
await Promise.all(
users
.filter(
(user) =>
user.hasPermission(Permission.MANAGE_REQUESTS) &&
user.settings?.hasNotificationType(
NotificationAgentKey.TELEGRAM,
type
) &&
// Check if it's the user's own auto-approved request
(type !== Notification.MEDIA_AUTO_APPROVED ||
user.id !== payload.request?.requestedBy.id)
)
.map(async (user) => {
if (
user.settings?.telegramChatId &&
user.settings.telegramChatId !== settings.options.chatId
) {
logger.debug('Sending Telegram notification', {
label: 'Notifications',
recipient: user.displayName,
type: Notification[type],
subject: payload.subject,
});
try {
await axios.post(
endpoint,
this.buildMessage(
type,
payload,
user.settings.telegramChatId,
!!user.settings?.telegramSendSilently
)
);
} catch (e) {
logger.error('Error sending Telegram notification', {
label: 'Notifications',
recipient: user.displayName,
type: Notification[type],
subject: payload.subject,
errorMessage: e.message,
response: e.response?.data,
});
return false;
}
}
})
);
}
return true;
}
}

@ -113,12 +113,10 @@ class WebhookAgent
return this.parseKeys(parsedJSON, payload, type);
}
public shouldSend(type: Notification): boolean {
if (
this.getSettings().enabled &&
this.getSettings().options.webhookUrl &&
hasNotificationType(type, this.getSettings().types)
) {
public shouldSend(): boolean {
const settings = this.getSettings();
if (settings.enabled && settings.options.webhookUrl) {
return true;
}
@ -129,6 +127,12 @@ class WebhookAgent
type: Notification,
payload: NotificationPayload
): Promise<boolean> {
const settings = this.getSettings();
if (!hasNotificationType(type, settings.types ?? 0)) {
return true;
}
logger.debug('Sending webhook notification', {
label: 'Notifications',
type: Notification[type],
@ -136,17 +140,17 @@ class WebhookAgent
});
try {
const { webhookUrl, authHeader } = this.getSettings().options;
if (!webhookUrl) {
return false;
}
await axios.post(webhookUrl, this.buildPayload(type, payload), {
headers: {
Authorization: authHeader,
},
});
await axios.post(
settings.options.webhookUrl,
this.buildPayload(type, payload),
settings.options.authHeader
? {
headers: {
Authorization: settings.options.authHeader,
},
}
: undefined
);
return true;
} catch (e) {

@ -1,6 +1,6 @@
import { getRepository } from 'typeorm';
import webpush from 'web-push';
import { hasNotificationType, Notification } from '..';
import { Notification } from '..';
import { MediaType } from '../../../constants/media';
import { User } from '../../../entity/User';
import { UserPushSubscription } from '../../../entity/UserPushSubscription';
@ -135,11 +135,8 @@ class WebPushAgent
}
}
public shouldSend(type: Notification): boolean {
if (
this.getSettings().enabled &&
hasNotificationType(type, this.getSettings().types)
) {
public shouldSend(): boolean {
if (this.getSettings().enabled) {
return true;
}
@ -150,11 +147,6 @@ class WebPushAgent
type: Notification,
payload: NotificationPayload
): Promise<boolean> {
logger.debug('Sending web push notification', {
label: 'Notifications',
type: Notification[type],
subject: payload.subject,
});
const userRepository = getRepository(User);
const userPushSubRepository = getRepository(UserPushSubscription);
const settings = getSettings();
@ -213,8 +205,15 @@ class WebPushAgent
settings.vapidPrivate
);
Promise.all(
await Promise.all(
pushSubs.map(async (sub) => {
logger.debug('Sending web push notification', {
label: 'Notifications',
recipient: sub.user.displayName,
type: Notification[type],
subject: payload.subject,
});
try {
await webpush.sendNotification(
{
@ -230,12 +229,24 @@ class WebPushAgent
)
);
} catch (e) {
logger.error(
'Error sending web push notification; removing subscription',
{
label: 'Notifications',
recipient: sub.user.displayName,
type: Notification[type],
subject: payload.subject,
errorMessage: e.message,
}
);
// Failed to send notification so we need to remove the subscription
userPushSubRepository.remove(sub);
}
})
);
}
return true;
}
}

@ -30,6 +30,11 @@ export const hasNotificationType = (
total = types;
}
// Test notifications don't need to be enabled
if (!(value & Notification.TEST_NOTIFICATION)) {
value += Notification.TEST_NOTIFICATION;
}
return !!(value & total);
};
@ -51,7 +56,7 @@ class NotificationManager {
});
this.activeAgents.forEach((agent) => {
if (agent.shouldSend(type)) {
if (agent.shouldSend()) {
agent.send(type, payload);
}
});

@ -113,7 +113,7 @@ interface FullPublicSettings extends PublicSettings {
export interface NotificationAgentConfig {
enabled: boolean;
types: number;
types?: number;
options: Record<string, unknown>;
}
export interface NotificationAgentDiscord extends NotificationAgentConfig {
@ -150,7 +150,7 @@ export interface NotificationAgentEmail extends NotificationAgentConfig {
export interface NotificationAgentLunaSea extends NotificationAgentConfig {
options: {
webhookUrl: string;
profileName: string;
profileName?: string;
};
}
@ -173,7 +173,6 @@ export interface NotificationAgentPushover extends NotificationAgentConfig {
options: {
accessToken: string;
userToken: string;
priority: number;
};
}
@ -181,7 +180,7 @@ export interface NotificationAgentWebhook extends NotificationAgentConfig {
options: {
webhookUrl: string;
jsonPayload: string;
authHeader: string;
authHeader?: string;
};
}
@ -272,7 +271,6 @@ class Settings {
agents: {
email: {
enabled: false,
types: 0,
options: {
emailFrom: '',
smtpHost: '',
@ -288,8 +286,6 @@ class Settings {
enabled: false,
types: 0,
options: {
botUsername: '',
botAvatarUrl: '',
webhookUrl: '',
},
},
@ -298,7 +294,6 @@ class Settings {
types: 0,
options: {
webhookUrl: '',
profileName: '',
},
},
slack: {
@ -312,7 +307,6 @@ class Settings {
enabled: false,
types: 0,
options: {
botUsername: '',
botAPI: '',
chatId: '',
sendSilently: false,
@ -331,7 +325,6 @@ class Settings {
options: {
accessToken: '',
userToken: '',
priority: 0,
},
},
webhook: {
@ -339,14 +332,12 @@ class Settings {
types: 0,
options: {
webhookUrl: '',
authHeader: '',
jsonPayload:
'IntcbiAgICBcIm5vdGlmaWNhdGlvbl90eXBlXCI6IFwie3tub3RpZmljYXRpb25fdHlwZX19XCIsXG4gICAgXCJzdWJqZWN0XCI6IFwie3tzdWJqZWN0fX1cIixcbiAgICBcIm1lc3NhZ2VcIjogXCJ7e21lc3NhZ2V9fVwiLFxuICAgIFwiaW1hZ2VcIjogXCJ7e2ltYWdlfX1cIixcbiAgICBcImVtYWlsXCI6IFwie3tub3RpZnl1c2VyX2VtYWlsfX1cIixcbiAgICBcInVzZXJuYW1lXCI6IFwie3tub3RpZnl1c2VyX3VzZXJuYW1lfX1cIixcbiAgICBcImF2YXRhclwiOiBcInt7bm90aWZ5dXNlcl9hdmF0YXJ9fVwiLFxuICAgIFwie3ttZWRpYX19XCI6IHtcbiAgICAgICAgXCJtZWRpYV90eXBlXCI6IFwie3ttZWRpYV90eXBlfX1cIixcbiAgICAgICAgXCJ0bWRiSWRcIjogXCJ7e21lZGlhX3RtZGJpZH19XCIsXG4gICAgICAgIFwiaW1kYklkXCI6IFwie3ttZWRpYV9pbWRiaWR9fVwiLFxuICAgICAgICBcInR2ZGJJZFwiOiBcInt7bWVkaWFfdHZkYmlkfX1cIixcbiAgICAgICAgXCJzdGF0dXNcIjogXCJ7e21lZGlhX3N0YXR1c319XCIsXG4gICAgICAgIFwic3RhdHVzNGtcIjogXCJ7e21lZGlhX3N0YXR1czRrfX1cIlxuICAgIH0sXG4gICAgXCJ7e2V4dHJhfX1cIjogW10sXG4gICAgXCJ7e3JlcXVlc3R9fVwiOiB7XG4gICAgICAgIFwicmVxdWVzdF9pZFwiOiBcInt7cmVxdWVzdF9pZH19XCIsXG4gICAgICAgIFwicmVxdWVzdGVkQnlfZW1haWxcIjogXCJ7e3JlcXVlc3RlZEJ5X2VtYWlsfX1cIixcbiAgICAgICAgXCJyZXF1ZXN0ZWRCeV91c2VybmFtZVwiOiBcInt7cmVxdWVzdGVkQnlfdXNlcm5hbWV9fVwiLFxuICAgICAgICBcInJlcXVlc3RlZEJ5X2F2YXRhclwiOiBcInt7cmVxdWVzdGVkQnlfYXZhdGFyfX1cIlxuICAgIH1cbn0i',
},
},
webpush: {
enabled: false,
types: 0,
options: {},
},
},

@ -238,7 +238,7 @@ userSettingsRoutes.get<{ id: string }, UserSettingsNotificationsResponse>(
isOwnProfileOrAdmin(),
async (req, res, next) => {
const userRepository = getRepository(User);
const settings = getSettings();
const settings = getSettings()?.notifications.agents;
try {
const user = await userRepository.findOne({
@ -250,16 +250,18 @@ userSettingsRoutes.get<{ id: string }, UserSettingsNotificationsResponse>(
}
return res.status(200).json({
emailEnabled: settings?.notifications.agents.email.enabled,
emailEnabled: settings?.email.enabled,
pgpKey: user.settings?.pgpKey,
discordEnabled: settings?.notifications.agents.discord.enabled,
discordEnabled: settings?.discord.enabled,
discordEnabledTypes: settings?.discord.enabled
? settings?.discord.types
: 0,
discordId: user.settings?.discordId,
telegramEnabled: settings?.notifications.agents.telegram.enabled,
telegramBotUsername:
settings?.notifications.agents.telegram.options.botUsername,
telegramEnabled: settings?.telegram.enabled,
telegramBotUsername: settings?.telegram.options.botUsername,
telegramChatId: user.settings?.telegramChatId,
telegramSendSilently: user?.settings?.telegramSendSilently,
webPushEnabled: settings?.notifications.agents.webpush.enabled,
webPushEnabled: settings?.webpush.enabled,
notificationTypes: user.settings?.notificationTypes ?? {},
});
} catch (e) {

@ -1 +1 @@
<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg"><path d="m81.9 83.9c-5.7 0-10.2 5-10.2 11.1s4.6 11.1 10.2 11.1c5.7 0 10.2-5 10.2-11.1 0.1-6.1-4.5-11.1-10.2-11.1zm36.5 0c-5.7 0-10.2 5-10.2 11.1s4.6 11.1 10.2 11.1c5.7 0 10.2-5 10.2-11.1s-4.5-11.1-10.2-11.1z" fill="currentColor"/><path d="m167 0h-134c-11.3 0-20.5 9.2-20.5 20.6v135.2c0 11.4 9.2 20.6 20.5 20.6h113.4l-5.3-18.5 12.8 11.9 12.1 11.2 21.5 19v-179.4c0-11.4-9.2-20.6-20.5-20.6zm-38.6 130.6s-3.6-4.3-6.6-8.1c13.1-3.7 18.1-11.9 18.1-11.9-4.1 2.7-8 4.6-11.5 5.9-5 2.1-9.8 3.5-14.5 4.3-9.6 1.8-18.4 1.3-25.9-0.1-5.7-1.1-10.6-2.7-14.7-4.3-2.3-0.9-4.8-2-7.3-3.4-0.3-0.2-0.6-0.3-0.9-0.5-0.2-0.1-0.3-0.2-0.4-0.3-1.8-1-2.8-1.7-2.8-1.7s4.8 8 17.5 11.8c-3 3.8-6.7 8.3-6.7 8.3-22.1-0.7-30.5-15.2-30.5-15.2 0-32.2 14.4-58.3 14.4-58.3 14.4-10.8 28.1-10.5 28.1-10.5l1 1.2c-18 5.2-26.3 13.1-26.3 13.1s2.2-1.2 5.9-2.9c10.7-4.7 19.2-6 22.7-6.3 0.6-0.1 1.1-0.2 1.7-0.2 6.1-0.8 13-1 20.2-0.2 9.5 1.1 19.7 3.9 30.1 9.6 0 0-7.9-7.5-24.9-12.7l1.4-1.6s13.7-0.3 28.1 10.5c0 0 14.4 26.1 14.4 58.3 0 0-8.5 14.5-30.6 15.2z" fill="currentColor"/></svg>
<svg viewBox="0 0 71 71" xmlns="http://www.w3.org/2000/svg"><path transform="translate(-1.0994e-7 8.0294)" d="m60.104 4.8978c-4.5253-2.0764-9.378-3.6062-14.452-4.4824-0.0924-0.01691-0.1847 0.025349-0.2323 0.10987-0.6241 1.11-1.3154 2.5581-1.7995 3.6963-5.4572-0.817-10.886-0.817-16.232 0-0.4842-1.1635-1.2006-2.5863-1.8275-3.6963-0.0476-0.0817-0.1399-0.12396-0.2323-0.10987-5.071 0.87338-9.9237 2.4032-14.452 4.4824-0.0392 0.0169-0.0728 0.0451-0.0951 0.0817-9.2046 13.751-11.726 27.165-10.489 40.412 0.005597 0.0648 0.041978 0.1268 0.092353 0.1662 6.0729 4.4598 11.956 7.1673 17.729 8.9619 0.0924 0.0282 0.1903-0.0056 0.2491-0.0817 1.3657-1.865 2.5831-3.8315 3.6269-5.8995 0.0616-0.1211 0.0028-0.2648-0.1231-0.3127-1.931-0.7325-3.7697-1.6256-5.5384-2.6398-0.1399-0.0817-0.1511-0.2818-0.0224-0.3776 0.3722-0.2789 0.7445-0.5691 1.0999-0.8621 0.0643-0.0535 0.1539-0.0648 0.2295-0.031 11.62 5.3051 24.199 5.3051 35.682 0 0.0756-0.0366 0.1652-0.0253 0.2323 0.0282 0.3555 0.293 0.7277 0.586 1.1027 0.8649 0.1287 0.0958 0.1203 0.2959-0.0196 0.3776-1.7687 1.0339-3.6074 1.9073-5.5412 2.637-0.1259 0.0479-0.1819 0.1944-0.1203 0.3155 1.0662 2.0651 2.2836 4.0316 3.6241 5.8967 0.056 0.0789 0.1567 0.1127 0.2491 0.0845 5.8014-1.7946 11.684-4.5021 17.757-8.9619 0.0532-0.0394 0.0868-0.0986 0.0924-0.1634 1.4804-15.315-2.4796-28.618-10.498-40.412-0.0196-0.0394-0.0531-0.0676-0.0923-0.0845zm-36.379 32.428c-3.4983 0-6.3808-3.2117-6.3808-7.156s2.8266-7.156 6.3808-7.156c3.5821 0 6.4367 3.2399 6.3807 7.156 0 3.9443-2.8266 7.156-6.3807 7.156zm23.592 0c-3.4982 0-6.3807-3.2117-6.3807-7.156s2.8265-7.156 6.3807-7.156c3.5822 0 6.4367 3.2399 6.3808 7.156 0 3.9443-2.7986 7.156-6.3808 7.156z" fill="currentColor"/></svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

@ -38,7 +38,7 @@ const NotificationType: React.FC<NotificationTypeProps> = ({
: currentTypes + option.value
);
}}
defaultChecked={
checked={
hasNotificationType(option.value, currentTypes) ||
(!!parent?.value &&
hasNotificationType(parent.value, currentTypes))
@ -46,10 +46,12 @@ const NotificationType: React.FC<NotificationTypeProps> = ({
/>
</div>
<div className="ml-3 text-sm leading-6">
<label htmlFor={option.id} className="font-medium text-white">
{option.name}
<label htmlFor={option.id} className="block font-medium text-white">
<div className="flex flex-col">
<span>{option.name}</span>
<span className="text-gray-500">{option.description}</span>
</div>
</label>
<p className="text-gray-500">{option.description}</p>
</div>
</div>
{(option.children ?? []).map((child) => (

@ -1,27 +1,42 @@
import React from 'react';
import { sortBy } from 'lodash';
import React, { useMemo, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import useSettings from '../../hooks/useSettings';
import { Permission, User, useUser } from '../../hooks/useUser';
import NotificationType from './NotificationType';
const messages = defineMessages({
notificationTypes: 'Notification Types',
mediarequested: 'Media Requested',
mediarequestedDescription:
'Sends a notification when media is requested and requires approval.',
'Send notifications when users submit new media requests which require approval.',
usermediarequestedDescription:
'Get notified when other users submit new media requests which require approval.',
mediaapproved: 'Media Approved',
mediaapprovedDescription:
'Sends a notification when requested media is manually approved.',
'Send notifications when media requests are manually approved.',
usermediaapprovedDescription:
'Get notified when your media requests are approved.',
mediaAutoApproved: 'Media Automatically Approved',
mediaAutoApprovedDescription:
'Sends a notification when requested media is automatically approved.',
'Send notifications when users submit new media requests which are automatically approved.',
usermediaAutoApprovedDescription:
'Get notified when other users submit new media requests which are automatically approved.',
mediaavailable: 'Media Available',
mediaavailableDescription:
'Sends a notification when requested media becomes available.',
'Send notifications when media requests become available.',
usermediaavailableDescription:
'Get notified when your media requests become available.',
mediafailed: 'Media Failed',
mediafailedDescription:
'Sends a notification when requested media fails to be added to Radarr or Sonarr.',
'Send notifications when media requests fail to be added to Radarr or Sonarr.',
usermediafailedDescription:
'Get notified when media requests fail to be added to Radarr or Sonarr.',
mediadeclined: 'Media Declined',
mediadeclinedDescription:
'Sends a notification when a media request is declined.',
'Send notifications when media requests are declined.',
usermediadeclinedDescription:
'Get notified when your media requests are declined.',
});
export const hasNotificationType = (
@ -30,16 +45,23 @@ export const hasNotificationType = (
): boolean => {
let total = 0;
// If we are not checking any notifications, bail out and return true
if (types === 0) {
return true;
}
if (Array.isArray(types)) {
// Combine all notification values into one
total = types.reduce((a, v) => a + v, 0);
} else {
total = types;
}
// Test notifications don't need to be enabled
if (!(value & Notification.TEST_NOTIFICATION)) {
value += Notification.TEST_NOTIFICATION;
}
return !!(value & total);
};
@ -63,69 +85,183 @@ export interface NotificationItem {
name: string;
description: string;
value: Notification;
hasNotifyUser?: boolean;
children?: NotificationItem[];
hidden?: boolean;
}
interface NotificationTypeSelectorProps {
user?: User;
enabledTypes?: number;
currentTypes: number;
onUpdate: (newTypes: number) => void;
error?: string;
}
const NotificationTypeSelector: React.FC<NotificationTypeSelectorProps> = ({
user,
enabledTypes = ALL_NOTIFICATIONS,
currentTypes,
onUpdate,
error,
}) => {
const intl = useIntl();
const settings = useSettings();
const { hasPermission } = useUser({ id: user?.id });
const [allowedTypes, setAllowedTypes] = useState(enabledTypes);
const availableTypes = useMemo(() => {
const allRequestsAutoApproved =
user &&
// Has Manage Requests perm, which grants all Auto-Approve perms
(hasPermission(Permission.MANAGE_REQUESTS) ||
// Cannot submit requests of any type
!hasPermission(
[
Permission.REQUEST,
Permission.REQUEST_MOVIE,
Permission.REQUEST_TV,
Permission.REQUEST_4K,
Permission.REQUEST_4K_MOVIE,
Permission.REQUEST_4K_TV,
],
{ type: 'or' }
) ||
// Cannot submit non-4K movie requests OR has Auto-Approve perms for non-4K movies
((!hasPermission([Permission.REQUEST, Permission.REQUEST_MOVIE], {
type: 'or',
}) ||
hasPermission(
[Permission.AUTO_APPROVE, Permission.AUTO_APPROVE_MOVIE],
{ type: 'or' }
)) &&
// Cannot submit non-4K series requests OR has Auto-Approve perms for non-4K series
(!hasPermission([Permission.REQUEST, Permission.REQUEST_TV], {
type: 'or',
}) ||
hasPermission(
[Permission.AUTO_APPROVE, Permission.AUTO_APPROVE_TV],
{ type: 'or' }
)) &&
// Cannot submit 4K movie requests OR has Auto-Approve perms for 4K movies
(!settings.currentSettings.movie4kEnabled ||
!hasPermission(
[Permission.REQUEST_4K, Permission.REQUEST_4K_MOVIE],
{ type: 'or' }
) ||
hasPermission(
[Permission.AUTO_APPROVE_4K, Permission.AUTO_APPROVE_4K_MOVIE],
{ type: 'or' }
)) &&
// Cannot submit 4K series requests OR has Auto-Approve perms for 4K series
(!settings.currentSettings.series4kEnabled ||
!hasPermission([Permission.REQUEST_4K, Permission.REQUEST_4K_TV], {
type: 'or',
}) ||
hasPermission(
[Permission.AUTO_APPROVE_4K, Permission.AUTO_APPROVE_4K_TV],
{ type: 'or' }
))));
const types: NotificationItem[] = [
{
id: 'media-requested',
name: intl.formatMessage(messages.mediarequested),
description: intl.formatMessage(
user
? messages.usermediarequestedDescription
: messages.mediarequestedDescription
),
value: Notification.MEDIA_PENDING,
hidden: user && !hasPermission(Permission.MANAGE_REQUESTS),
},
{
id: 'media-auto-approved',
name: intl.formatMessage(messages.mediaAutoApproved),
description: intl.formatMessage(
user
? messages.usermediaAutoApprovedDescription
: messages.mediaAutoApprovedDescription
),
value: Notification.MEDIA_AUTO_APPROVED,
hidden: user && !hasPermission(Permission.MANAGE_REQUESTS),
},
{
id: 'media-approved',
name: intl.formatMessage(messages.mediaapproved),
description: intl.formatMessage(
user
? messages.usermediaapprovedDescription
: messages.mediaapprovedDescription
),
value: Notification.MEDIA_APPROVED,
hasNotifyUser: true,
hidden: allRequestsAutoApproved,
},
{
id: 'media-declined',
name: intl.formatMessage(messages.mediadeclined),
description: intl.formatMessage(
user
? messages.usermediadeclinedDescription
: messages.mediadeclinedDescription
),
value: Notification.MEDIA_DECLINED,
hasNotifyUser: true,
hidden: allRequestsAutoApproved,
},
{
id: 'media-available',
name: intl.formatMessage(messages.mediaavailable),
description: intl.formatMessage(
user
? messages.usermediaavailableDescription
: messages.mediaavailableDescription
),
value: Notification.MEDIA_AVAILABLE,
hasNotifyUser: true,
},
{
id: 'media-failed',
name: intl.formatMessage(messages.mediafailed),
description: intl.formatMessage(
user
? messages.usermediafailedDescription
: messages.mediafailedDescription
),
value: Notification.MEDIA_FAILED,
hidden: user && !hasPermission(Permission.MANAGE_REQUESTS),
},
];
const types: NotificationItem[] = [
{
id: 'media-requested',
name: intl.formatMessage(messages.mediarequested),
description: intl.formatMessage(messages.mediarequestedDescription),
value: Notification.MEDIA_PENDING,
},
{
id: 'media-auto-approved',
name: intl.formatMessage(messages.mediaAutoApproved),
description: intl.formatMessage(messages.mediaAutoApprovedDescription),
value: Notification.MEDIA_AUTO_APPROVED,
},
{
id: 'media-approved',
name: intl.formatMessage(messages.mediaapproved),
description: intl.formatMessage(messages.mediaapprovedDescription),
value: Notification.MEDIA_APPROVED,
},
{
id: 'media-declined',
name: intl.formatMessage(messages.mediadeclined),
description: intl.formatMessage(messages.mediadeclinedDescription),
value: Notification.MEDIA_DECLINED,
},
{
id: 'media-available',
name: intl.formatMessage(messages.mediaavailable),
description: intl.formatMessage(messages.mediaavailableDescription),
value: Notification.MEDIA_AVAILABLE,
},
{
id: 'media-failed',
name: intl.formatMessage(messages.mediafailed),
description: intl.formatMessage(messages.mediafailedDescription),
value: Notification.MEDIA_FAILED,
},
];
const filteredTypes = types.filter(
(type) => !type.hidden && hasNotificationType(type.value, enabledTypes)
);
const newAllowedTypes = filteredTypes.reduce((a, v) => a + v.value, 0);
if (newAllowedTypes !== allowedTypes) {
setAllowedTypes(newAllowedTypes);
}
return user
? sortBy(filteredTypes, 'hasNotifyUser', 'DESC')
: filteredTypes;
}, [user, hasPermission, settings, intl, allowedTypes, enabledTypes]);
if (!availableTypes.length) {
return null;
}
return (
<div role="group" aria-labelledby="group-label" className="form-group">
<div className="form-row">
<span id="group-label" className="group-label">
{intl.formatMessage(messages.notificationTypes)}
<span className="label-required">*</span>
{!user && <span className="label-required">*</span>}
</span>
<div className="form-input">
<div className="max-w-lg">
{types.map((type) => (
{availableTypes.map((type) => (
<NotificationType
key={`notification-type-${type.id}`}
option={type}
@ -134,6 +270,7 @@ const NotificationTypeSelector: React.FC<NotificationTypeSelectorProps> = ({
/>
))}
</div>
{error && <div className="error">{error}</div>}
</div>
</div>
</div>

@ -23,6 +23,7 @@ const messages = defineMessages({
toastDiscordTestSuccess: 'Discord test notification sent!',
toastDiscordTestFailed: 'Discord test notification failed to send.',
validationUrl: 'You must provide a valid URL',
validationTypes: 'You must select at least one notification type',
});
const NotificationsDiscord: React.FC = () => {
@ -46,6 +47,13 @@ const NotificationsDiscord: React.FC = () => {
otherwise: Yup.string().nullable(),
})
.url(intl.formatMessage(messages.validationUrl)),
types: Yup.number().when('enabled', {
is: true,
then: Yup.number()
.nullable()
.moreThan(0, intl.formatMessage(messages.validationTypes)),
otherwise: Yup.number().nullable(),
}),
});
if (!data && !error) {
@ -88,7 +96,15 @@ const NotificationsDiscord: React.FC = () => {
}
}}
>
{({ errors, touched, isSubmitting, values, isValid, setFieldValue }) => {
{({
errors,
touched,
isSubmitting,
values,
isValid,
setFieldValue,
setFieldTouched,
}) => {
const testSettings = async () => {
setIsTesting(true);
let toastId: string | undefined;
@ -211,8 +227,20 @@ const NotificationsDiscord: React.FC = () => {
</div>
</div>
<NotificationTypeSelector
currentTypes={values.types}
onUpdate={(newTypes) => setFieldValue('types', newTypes)}
currentTypes={values.enabled ? values.types : 0}
onUpdate={(newTypes) => {
setFieldValue('types', newTypes);
setFieldTouched('types');
if (newTypes) {
setFieldValue('enabled', true);
}
}}
error={
errors.types && touched.types
? (errors.types as string)
: undefined
}
/>
<div className="actions">
<div className="flex justify-end">

@ -6,12 +6,10 @@ import { useToasts } from 'react-toast-notifications';
import useSWR from 'swr';
import * as Yup from 'yup';
import globalMessages from '../../../i18n/globalMessages';
import Alert from '../../Common/Alert';
import Badge from '../../Common/Badge';
import Button from '../../Common/Button';
import LoadingSpinner from '../../Common/LoadingSpinner';
import SensitiveInput from '../../Common/SensitiveInput';
import NotificationTypeSelector from '../../NotificationTypeSelector';
const messages = defineMessages({
validationSmtpHostRequired: 'You must provide a valid hostname or IP address',
@ -37,20 +35,14 @@ const messages = defineMessages({
allowselfsigned: 'Allow Self-Signed Certificates',
senderName: 'Sender Name',
validationEmail: 'You must provide a valid email address',
emailNotificationTypesAlertDescription:
'<strong>Media Requested</strong>, <strong>Media Automatically Approved</strong>, and <strong>Media Failed</strong> email notifications are sent to all users with the <strong>Manage Requests</strong> permission.',
emailNotificationTypesAlertDescriptionPt2:
'<strong>Media Approved</strong>, <strong>Media Declined</strong>, and <strong>Media Available</strong> email notifications are sent to the user who submitted the request.',
pgpPrivateKey: 'PGP Private Key',
pgpPrivateKeyTip:
'Sign encrypted email messages using <OpenPgpLink>OpenPGP</OpenPgpLink>',
validationPgpPrivateKey:
'You must provide a valid PGP private key if a PGP password is entered',
validationPgpPrivateKey: 'You must provide a valid PGP private key',
pgpPassword: 'PGP Password',
pgpPasswordTip:
'Sign encrypted email messages using <OpenPgpLink>OpenPGP</OpenPgpLink>',
validationPgpPassword:
'You must provide a PGP password if a PGP private key is entered',
validationPgpPassword: 'You must provide a PGP password',
});
export function OpenPgpLink(msg: string): JSX.Element {
@ -130,7 +122,6 @@ const NotificationsEmail: React.FC = () => {
<Formik
initialValues={{
enabled: data.enabled,
types: data.types,
emailFrom: data.options.emailFrom,
smtpHost: data.options.smtpHost,
smtpPort: data.options.smtpPort ?? 587,
@ -153,7 +144,6 @@ const NotificationsEmail: React.FC = () => {
try {
await axios.post('/api/v1/settings/notifications/email', {
enabled: values.enabled,
types: values.types,
options: {
emailFrom: values.emailFrom,
smtpHost: values.smtpHost,
@ -184,7 +174,7 @@ const NotificationsEmail: React.FC = () => {
}
}}
>
{({ errors, touched, isSubmitting, values, isValid, setFieldValue }) => {
{({ errors, touched, isSubmitting, values, isValid }) => {
const testSettings = async () => {
setIsTesting(true);
let toastId: string | undefined;
@ -201,7 +191,6 @@ const NotificationsEmail: React.FC = () => {
);
await axios.post('/api/v1/settings/notifications/email/test', {
enabled: true,
types: values.types,
options: {
emailFrom: values.emailFrom,
smtpHost: values.smtpHost,
@ -238,274 +227,234 @@ const NotificationsEmail: React.FC = () => {
};
return (
<>
<Alert
title={
<>
<p className="mb-2">
{intl.formatMessage(
messages.emailNotificationTypesAlertDescription,
{
strong: function strong(msg) {
return (
<strong className="font-semibold text-indigo-100">
{msg}
</strong>
);
},
}
)}
</p>
<p>
{intl.formatMessage(
messages.emailNotificationTypesAlertDescriptionPt2,
{
strong: function strong(msg) {
return (
<strong className="font-semibold text-indigo-100">
{msg}
</strong>
);
},
}
)}
</p>
</>
}
type="info"
/>
<Form className="section">
<div className="form-row">
<label htmlFor="enabled" className="checkbox-label">
{intl.formatMessage(messages.agentenabled)}
<span className="label-required">*</span>
</label>
<div className="form-input">
<Field type="checkbox" id="enabled" name="enabled" />
</div>
</div>
<div className="form-row">
<label htmlFor="senderName" className="text-label">
{intl.formatMessage(messages.senderName)}
</label>
<div className="form-input">
<div className="form-input-field">
<Field id="senderName" name="senderName" type="text" />
</div>
</div>
<Form className="section">
<div className="form-row">
<label htmlFor="enabled" className="checkbox-label">
{intl.formatMessage(messages.agentenabled)}
<span className="label-required">*</span>
</label>
<div className="form-input">
<Field type="checkbox" id="enabled" name="enabled" />
</div>
<div className="form-row">
<label htmlFor="emailFrom" className="text-label">
{intl.formatMessage(messages.emailsender)}
<span className="label-required">*</span>
</label>
<div className="form-input">
<div className="form-input-field">
<Field
id="emailFrom"
name="emailFrom"
type="text"
inputMode="email"
/>
</div>
{errors.emailFrom && touched.emailFrom && (
<div className="error">{errors.emailFrom}</div>
)}
</div>
<div className="form-row">
<label htmlFor="senderName" className="text-label">
{intl.formatMessage(messages.senderName)}
</label>
<div className="form-input">
<div className="form-input-field">
<Field id="senderName" name="senderName" type="text" />
</div>
</div>
<div className="form-row">
<label htmlFor="smtpHost" className="text-label">
{intl.formatMessage(messages.smtpHost)}
<span className="label-required">*</span>
</label>
<div className="form-input">
<div className="form-input-field">
<Field
id="smtpHost"
name="smtpHost"
type="text"
inputMode="url"
/>
</div>
{errors.smtpHost && touched.smtpHost && (
<div className="error">{errors.smtpHost}</div>
)}
</div>
</div>
<div className="form-row">
<label htmlFor="smtpPort" className="text-label">
{intl.formatMessage(messages.smtpPort)}
<span className="label-required">*</span>
</label>
<div className="form-input">
</div>
<div className="form-row">
<label htmlFor="emailFrom" className="text-label">
{intl.formatMessage(messages.emailsender)}
<span className="label-required">*</span>
</label>
<div className="form-input">
<div className="form-input-field">
<Field
id="smtpPort"
name="smtpPort"
id="emailFrom"
name="emailFrom"
type="text"
inputMode="numeric"
className="short"
inputMode="email"
/>
{errors.smtpPort && touched.smtpPort && (
<div className="error">{errors.smtpPort}</div>
)}
</div>
</div>
<div className="form-row">
<label htmlFor="encryption" className="text-label">
{intl.formatMessage(messages.encryption)}
<span className="label-required">*</span>
</label>
<div className="form-input">
<div className="form-input-field">
<Field as="select" id="encryption" name="encryption">
<option value="none">
{intl.formatMessage(messages.encryptionNone)}
</option>
<option value="default">
{intl.formatMessage(messages.encryptionDefault)}
</option>
<option value="opportunistic">
{intl.formatMessage(
messages.encryptionOpportunisticTls
)}
</option>
<option value="implicit">
{intl.formatMessage(messages.encryptionImplicitTls)}
</option>
</Field>
</div>
</div>
{errors.emailFrom && touched.emailFrom && (
<div className="error">{errors.emailFrom}</div>
)}
</div>
<div className="form-row">
<label htmlFor="allowSelfSigned" className="checkbox-label">
{intl.formatMessage(messages.allowselfsigned)}
</label>
<div className="form-input">
</div>
<div className="form-row">
<label htmlFor="smtpHost" className="text-label">
{intl.formatMessage(messages.smtpHost)}
<span className="label-required">*</span>
</label>
<div className="form-input">
<div className="form-input-field">
<Field
type="checkbox"
id="allowSelfSigned"
name="allowSelfSigned"
id="smtpHost"
name="smtpHost"
type="text"
inputMode="url"
/>
</div>
{errors.smtpHost && touched.smtpHost && (
<div className="error">{errors.smtpHost}</div>
)}
</div>
</div>
<div className="form-row">
<label htmlFor="smtpPort" className="text-label">
{intl.formatMessage(messages.smtpPort)}
<span className="label-required">*</span>
</label>
<div className="form-input">
<Field
id="smtpPort"
name="smtpPort"
type="text"
inputMode="numeric"
className="short"
/>
{errors.smtpPort && touched.smtpPort && (
<div className="error">{errors.smtpPort}</div>
)}
</div>
<div className="form-row">
<label htmlFor="authUser" className="text-label">
{intl.formatMessage(messages.authUser)}
</label>
<div className="form-input">
<div className="form-input-field">
<Field id="authUser" name="authUser" type="text" />
</div>
</div>
<div className="form-row">
<label htmlFor="encryption" className="text-label">
{intl.formatMessage(messages.encryption)}
<span className="label-required">*</span>
<span className="label-tip">
{intl.formatMessage(messages.encryptionTip)}
</span>
</label>
<div className="form-input">
<div className="form-input-field">
<Field as="select" id="encryption" name="encryption">
<option value="none">
{intl.formatMessage(messages.encryptionNone)}
</option>
<option value="default">
{intl.formatMessage(messages.encryptionDefault)}
</option>
<option value="opportunistic">
{intl.formatMessage(messages.encryptionOpportunisticTls)}
</option>
<option value="implicit">
{intl.formatMessage(messages.encryptionImplicitTls)}
</option>
</Field>
</div>
</div>
<div className="form-row">
<label htmlFor="authPass" className="text-label">
{intl.formatMessage(messages.authPass)}
</label>
<div className="form-input">
<div className="form-input-field">
<SensitiveInput
as="field"
id="authPass"
name="authPass"
autoComplete="one-time-code"
/>
</div>
</div>
<div className="form-row">
<label htmlFor="allowSelfSigned" className="checkbox-label">
{intl.formatMessage(messages.allowselfsigned)}
</label>
<div className="form-input">
<Field
type="checkbox"
id="allowSelfSigned"
name="allowSelfSigned"
/>
</div>
</div>
<div className="form-row">
<label htmlFor="authUser" className="text-label">
{intl.formatMessage(messages.authUser)}
</label>
<div className="form-input">
<div className="form-input-field">
<Field id="authUser" name="authUser" type="text" />
</div>
</div>
<div className="form-row">
<label htmlFor="pgpPrivateKey" className="text-label">
<span className="mr-2">
{intl.formatMessage(messages.pgpPrivateKey)}
</span>
<Badge badgeType="danger">
{intl.formatMessage(globalMessages.advanced)}
</Badge>
<span className="label-tip">
{intl.formatMessage(messages.pgpPrivateKeyTip, {
OpenPgpLink: OpenPgpLink,
})}
</span>
</label>
<div className="form-input">
<div className="form-input-field">
<SensitiveInput
as="field"
id="pgpPrivateKey"
name="pgpPrivateKey"
type="textarea"
rows="10"
className="font-mono text-xs"
/>
</div>
{errors.pgpPrivateKey && touched.pgpPrivateKey && (
<div className="error">{errors.pgpPrivateKey}</div>
)}
</div>
<div className="form-row">
<label htmlFor="authPass" className="text-label">
{intl.formatMessage(messages.authPass)}
</label>
<div className="form-input">
<div className="form-input-field">
<SensitiveInput
as="field"
id="authPass"
name="authPass"
autoComplete="one-time-code"
/>
</div>
</div>
<div className="form-row">
<label htmlFor="pgpPassword" className="text-label">
<span className="mr-2">
{intl.formatMessage(messages.pgpPassword)}
</span>
<Badge badgeType="danger">
{intl.formatMessage(globalMessages.advanced)}
</Badge>
<span className="label-tip">
{intl.formatMessage(messages.pgpPasswordTip, {
OpenPgpLink: OpenPgpLink,
})}
</span>
</label>
<div className="form-input">
<div className="form-input-field">
<SensitiveInput
as="field"
id="pgpPassword"
name="pgpPassword"
autoComplete="one-time-code"
/>
</div>
{errors.pgpPassword && touched.pgpPassword && (
<div className="error">{errors.pgpPassword}</div>
)}
</div>
<div className="form-row">
<label htmlFor="pgpPrivateKey" className="text-label">
<span className="mr-2">
{intl.formatMessage(messages.pgpPrivateKey)}
</span>
<Badge badgeType="danger">
{intl.formatMessage(globalMessages.advanced)}
</Badge>
<span className="label-tip">
{intl.formatMessage(messages.pgpPrivateKeyTip, {
OpenPgpLink: OpenPgpLink,
})}
</span>
</label>
<div className="form-input">
<div className="form-input-field">
<SensitiveInput
as="field"
id="pgpPrivateKey"
name="pgpPrivateKey"
type="textarea"
rows="10"
className="font-mono text-xs"
/>
</div>
{errors.pgpPrivateKey && touched.pgpPrivateKey && (
<div className="error">{errors.pgpPrivateKey}</div>
)}
</div>
<NotificationTypeSelector
currentTypes={values.types}
onUpdate={(newTypes) => setFieldValue('types', newTypes)}
/>
<div className="actions">
<div className="flex justify-end">
<span className="inline-flex ml-3 rounded-md shadow-sm">
<Button
buttonType="warning"
disabled={isSubmitting || !isValid || isTesting}
onClick={(e) => {
e.preventDefault();
testSettings();
}}
>
{isTesting
? intl.formatMessage(globalMessages.testing)
: intl.formatMessage(globalMessages.test)}
</Button>
</span>
<span className="inline-flex ml-3 rounded-md shadow-sm">
<Button
buttonType="primary"
type="submit"
disabled={isSubmitting || !isValid || isTesting}
>
{isSubmitting
? intl.formatMessage(globalMessages.saving)
: intl.formatMessage(globalMessages.save)}
</Button>
</span>
</div>
<div className="form-row">
<label htmlFor="pgpPassword" className="text-label">
<span className="mr-2">
{intl.formatMessage(messages.pgpPassword)}
</span>
<Badge badgeType="danger">
{intl.formatMessage(globalMessages.advanced)}
</Badge>
<span className="label-tip">
{intl.formatMessage(messages.pgpPasswordTip, {
OpenPgpLink: OpenPgpLink,
})}
</span>
</label>
<div className="form-input">
<div className="form-input-field">
<SensitiveInput
as="field"
id="pgpPassword"
name="pgpPassword"
autoComplete="one-time-code"
/>
</div>
{errors.pgpPassword && touched.pgpPassword && (
<div className="error">{errors.pgpPassword}</div>
)}
</div>
</div>
<div className="actions">
<div className="flex justify-end">
<span className="inline-flex ml-3 rounded-md shadow-sm">
<Button
buttonType="warning"
disabled={isSubmitting || !isValid || isTesting}
onClick={(e) => {
e.preventDefault();
testSettings();
}}
>
{isTesting
? intl.formatMessage(globalMessages.testing)
: intl.formatMessage(globalMessages.test)}
</Button>
</span>
<span className="inline-flex ml-3 rounded-md shadow-sm">
<Button
buttonType="primary"
type="submit"
disabled={isSubmitting || !isValid || isTesting}
>
{isSubmitting
? intl.formatMessage(globalMessages.saving)
: intl.formatMessage(globalMessages.save)}
</Button>
</span>
</div>
</Form>
</>
</div>
</Form>
);
}}
</Formik>

@ -23,6 +23,7 @@ const messages = defineMessages({
toastLunaSeaTestSending: 'Sending LunaSea test notification…',
toastLunaSeaTestSuccess: 'LunaSea test notification sent!',
toastLunaSeaTestFailed: 'LunaSea test notification failed to send.',
validationTypes: 'You must select at least one notification type',
});
const NotificationsLunaSea: React.FC = () => {
@ -43,6 +44,13 @@ const NotificationsLunaSea: React.FC = () => {
otherwise: Yup.string().nullable(),
})
.url(intl.formatMessage(messages.validationWebhookUrl)),
types: Yup.number().when('enabled', {
is: true,
then: Yup.number()
.nullable()
.moreThan(0, intl.formatMessage(messages.validationTypes)),
otherwise: Yup.number().nullable(),
}),
});
if (!data && !error) {
@ -82,7 +90,15 @@ const NotificationsLunaSea: React.FC = () => {
}
}}
>
{({ errors, touched, isSubmitting, values, isValid, setFieldValue }) => {
{({
errors,
touched,
isSubmitting,
values,
isValid,
setFieldValue,
setFieldTouched,
}) => {
const testSettings = async () => {
setIsTesting(true);
let toastId: string | undefined;
@ -190,8 +206,20 @@ const NotificationsLunaSea: React.FC = () => {
</div>
</div>
<NotificationTypeSelector
currentTypes={values.types}
onUpdate={(newTypes) => setFieldValue('types', newTypes)}
currentTypes={values.enabled ? values.types : 0}
onUpdate={(newTypes) => {
setFieldValue('types', newTypes);
setFieldTouched('types');
if (newTypes) {
setFieldValue('enabled', true);
}
}}
error={
errors.types && touched.types
? (errors.types as string)
: undefined
}
/>
<div className="actions">
<div className="flex justify-end">

@ -23,6 +23,7 @@ const messages = defineMessages({
toastPushbulletTestSending: 'Sending Pushbullet test notification…',
toastPushbulletTestSuccess: 'Pushbullet test notification sent!',
toastPushbulletTestFailed: 'Pushbullet test notification failed to send.',
validationTypes: 'You must select at least one notification type',
});
const NotificationsPushbullet: React.FC = () => {
@ -41,6 +42,13 @@ const NotificationsPushbullet: React.FC = () => {
.required(intl.formatMessage(messages.validationAccessTokenRequired)),
otherwise: Yup.string().nullable(),
}),
types: Yup.number().when('enabled', {
is: true,
then: Yup.number()
.nullable()
.moreThan(0, intl.formatMessage(messages.validationTypes)),
otherwise: Yup.number().nullable(),
}),
});
if (!data && !error) {
@ -78,7 +86,15 @@ const NotificationsPushbullet: React.FC = () => {
}
}}
>
{({ errors, touched, isSubmitting, values, isValid, setFieldValue }) => {
{({
errors,
touched,
isSubmitting,
values,
isValid,
setFieldValue,
setFieldTouched,
}) => {
const testSettings = async () => {
setIsTesting(true);
let toastId: string | undefined;
@ -170,8 +186,20 @@ const NotificationsPushbullet: React.FC = () => {
</div>
</div>
<NotificationTypeSelector
currentTypes={values.types}
onUpdate={(newTypes) => setFieldValue('types', newTypes)}
currentTypes={values.enabled ? values.types : 0}
onUpdate={(newTypes) => {
setFieldValue('types', newTypes);
setFieldTouched('types');
if (newTypes) {
setFieldValue('enabled', true);
}
}}
error={
errors.types && touched.types
? (errors.types as string)
: undefined
}
/>
<div className="actions">
<div className="flex justify-end">

@ -19,12 +19,13 @@ const messages = defineMessages({
userTokenTip:
'Your 30-character <UsersGroupsLink>user or group identifier</UsersGroupsLink>',
validationAccessTokenRequired: 'You must provide a valid application token',
validationUserTokenRequired: 'You must provide a valid user key',
validationUserTokenRequired: 'You must provide a valid user or group key',
pushoversettingssaved: 'Pushover notification settings saved successfully!',
pushoversettingsfailed: 'Pushover notification settings failed to save.',
toastPushoverTestSending: 'Sending Pushover test notification…',
toastPushoverTestSuccess: 'Pushover test notification sent!',
toastPushoverTestFailed: 'Pushover test notification failed to send.',
validationTypes: 'You must select at least one notification type',
});
const NotificationsPushover: React.FC = () => {
@ -60,6 +61,13 @@ const NotificationsPushover: React.FC = () => {
/^[a-z\d]{30}$/i,
intl.formatMessage(messages.validationUserTokenRequired)
),
types: Yup.number().when('enabled', {
is: true,
then: Yup.number()
.nullable()
.moreThan(0, intl.formatMessage(messages.validationTypes)),
otherwise: Yup.number().nullable(),
}),
});
if (!data && !error) {
@ -99,7 +107,15 @@ const NotificationsPushover: React.FC = () => {
}
}}
>
{({ errors, touched, isSubmitting, values, isValid, setFieldValue }) => {
{({
errors,
touched,
isSubmitting,
values,
isValid,
setFieldValue,
setFieldTouched,
}) => {
const testSettings = async () => {
setIsTesting(true);
let toastId: string | undefined;
@ -216,8 +232,20 @@ const NotificationsPushover: React.FC = () => {
</div>
</div>
<NotificationTypeSelector
currentTypes={values.types}
onUpdate={(newTypes) => setFieldValue('types', newTypes)}
currentTypes={values.enabled ? values.types : 0}
onUpdate={(newTypes) => {
setFieldValue('types', newTypes);
setFieldTouched('types');
if (newTypes) {
setFieldValue('enabled', true);
}
}}
error={
errors.types && touched.types
? (errors.types as string)
: undefined
}
/>
<div className="actions">
<div className="flex justify-end">

@ -21,6 +21,7 @@ const messages = defineMessages({
toastSlackTestSuccess: 'Slack test notification sent!',
toastSlackTestFailed: 'Slack test notification failed to send.',
validationWebhookUrl: 'You must provide a valid URL',
validationTypes: 'You must select at least one notification type',
});
const NotificationsSlack: React.FC = () => {
@ -41,6 +42,13 @@ const NotificationsSlack: React.FC = () => {
otherwise: Yup.string().nullable(),
})
.url(intl.formatMessage(messages.validationWebhookUrl)),
types: Yup.number().when('enabled', {
is: true,
then: Yup.number()
.nullable()
.moreThan(0, intl.formatMessage(messages.validationTypes)),
otherwise: Yup.number().nullable(),
}),
});
if (!data && !error) {
@ -78,7 +86,15 @@ const NotificationsSlack: React.FC = () => {
}
}}
>
{({ errors, touched, isSubmitting, values, isValid, setFieldValue }) => {
{({
errors,
touched,
isSubmitting,
values,
isValid,
setFieldValue,
setFieldTouched,
}) => {
const testSettings = async () => {
setIsTesting(true);
let toastId: string | undefined;
@ -168,8 +184,20 @@ const NotificationsSlack: React.FC = () => {
</div>
</div>
<NotificationTypeSelector
currentTypes={values.types}
onUpdate={(newTypes) => setFieldValue('types', newTypes)}
currentTypes={values.enabled ? values.types : 0}
onUpdate={(newTypes) => {
setFieldValue('types', newTypes);
setFieldTouched('types');
if (newTypes) {
setFieldValue('enabled', true);
}
}}
error={
errors.types && touched.types
? (errors.types as string)
: undefined
}
/>
<div className="actions">
<div className="flex justify-end">

@ -105,7 +105,15 @@ const NotificationsTelegram: React.FC = () => {
}
}}
>
{({ errors, touched, isSubmitting, values, isValid, setFieldValue }) => {
{({
errors,
touched,
isSubmitting,
values,
isValid,
setFieldValue,
setFieldTouched,
}) => {
const testSettings = async () => {
setIsTesting(true);
let toastId: string | undefined;
@ -232,6 +240,24 @@ const NotificationsTelegram: React.FC = () => {
<label htmlFor="chatId" className="text-label">
{intl.formatMessage(messages.chatId)}
<span className="label-required">*</span>
<span className="label-tip">
{intl.formatMessage(messages.chatIdTip, {
GetIdBotLink: function GetIdBotLink(msg) {
return (
<a
href="https://telegram.me/get_id_bot"
target="_blank"
rel="noreferrer"
>
{msg}
</a>
);
},
code: function code(msg) {
return <code>{msg}</code>;
},
})}
</span>
</label>
<div className="form-input">
<div className="form-input-field">
@ -254,8 +280,20 @@ const NotificationsTelegram: React.FC = () => {
</div>
</div>
<NotificationTypeSelector
currentTypes={values.types}
onUpdate={(newTypes) => setFieldValue('types', newTypes)}
currentTypes={values.enabled ? values.types : 0}
onUpdate={(newTypes) => {
setFieldValue('types', newTypes);
setFieldTouched('types');
if (newTypes) {
setFieldValue('enabled', true);
}
}}
error={
errors.types && touched.types
? (errors.types as string)
: undefined
}
/>
<div className="actions">
<div className="flex justify-end">

@ -8,7 +8,6 @@ import globalMessages from '../../../../i18n/globalMessages';
import Alert from '../../../Common/Alert';
import Button from '../../../Common/Button';
import LoadingSpinner from '../../../Common/LoadingSpinner';
import NotificationTypeSelector from '../../../NotificationTypeSelector';
const messages = defineMessages({
agentenabled: 'Enable Agent',
@ -49,13 +48,11 @@ const NotificationsWebPush: React.FC = () => {
<Formik
initialValues={{
enabled: data.enabled,
types: data.types,
}}
onSubmit={async (values) => {
try {
await axios.post('/api/v1/settings/notifications/webpush', {
enabled: values.enabled,
types: values.types,
options: {},
});
mutate('/api/v1/settings/public');
@ -73,7 +70,7 @@ const NotificationsWebPush: React.FC = () => {
}
}}
>
{({ isSubmitting, values, isValid, setFieldValue }) => {
{({ isSubmitting }) => {
const testSettings = async () => {
setIsTesting(true);
let toastId: string | undefined;
@ -90,7 +87,6 @@ const NotificationsWebPush: React.FC = () => {
);
await axios.post('/api/v1/settings/notifications/webpush/test', {
enabled: true,
types: values.types,
options: {},
});
@ -125,16 +121,12 @@ const NotificationsWebPush: React.FC = () => {
<Field type="checkbox" id="enabled" name="enabled" />
</div>
</div>
<NotificationTypeSelector
currentTypes={values.types}
onUpdate={(newTypes) => setFieldValue('types', newTypes)}
/>
<div className="actions">
<div className="flex justify-end">
<span className="inline-flex ml-3 rounded-md shadow-sm">
<Button
buttonType="warning"
disabled={isSubmitting || !isValid || isTesting}
disabled={isSubmitting || isTesting}
onClick={(e) => {
e.preventDefault();
testSettings();
@ -149,7 +141,7 @@ const NotificationsWebPush: React.FC = () => {
<Button
buttonType="primary"
type="submit"
disabled={isSubmitting || !isValid || isTesting}
disabled={isSubmitting || isTesting}
>
{isSubmitting
? intl.formatMessage(globalMessages.saving)

@ -55,6 +55,7 @@ const messages = defineMessages({
customJson: 'JSON Payload',
templatevariablehelp: 'Template Variable Help',
validationWebhookUrl: 'You must provide a valid URL',
validationTypes: 'You must select at least one notification type',
});
const NotificationsWebhook: React.FC = () => {
@ -99,6 +100,13 @@ const NotificationsWebhook: React.FC = () => {
}
}
),
types: Yup.number().when('enabled', {
is: true,
then: Yup.number()
.nullable()
.moreThan(0, intl.formatMessage(messages.validationTypes)),
otherwise: Yup.number().nullable(),
}),
});
if (!data && !error) {
@ -293,8 +301,20 @@ const NotificationsWebhook: React.FC = () => {
</div>
</div>
<NotificationTypeSelector
currentTypes={values.types}
onUpdate={(newTypes) => setFieldValue('types', newTypes)}
currentTypes={values.enabled ? values.types : 0}
onUpdate={(newTypes) => {
setFieldValue('types', newTypes);
setFieldTouched('types');
if (newTypes) {
setFieldValue('enabled', true);
}
}}
error={
errors.types && touched.types
? (errors.types as string)
: undefined
}
/>
<div className="actions">
<div className="flex justify-end">

@ -11,12 +11,11 @@ import { useUser } from '../../../../hooks/useUser';
import globalMessages from '../../../../i18n/globalMessages';
import Button from '../../../Common/Button';
import LoadingSpinner from '../../../Common/LoadingSpinner';
import { ALL_NOTIFICATIONS } from '../../../NotificationTypeSelector';
import NotificationTypeSelector from '../../../NotificationTypeSelector';
const messages = defineMessages({
discordsettingssaved: 'Discord notification settings saved successfully!',
discordsettingsfailed: 'Discord notification settings failed to save.',
enableDiscord: 'Enable Mentions',
discordId: 'User ID',
discordIdTip:
'The <FindDiscordIdLink>ID number</FindDiscordIdLink> for your user account',
@ -34,8 +33,8 @@ const UserNotificationsDiscord: React.FC = () => {
const UserNotificationsDiscordSchema = Yup.object().shape({
discordId: Yup.string()
.when('enableDiscord', {
is: true,
.when('types', {
is: (value: unknown) => !!value,
then: Yup.string()
.nullable()
.required(intl.formatMessage(messages.validationDiscordId)),
@ -51,8 +50,10 @@ const UserNotificationsDiscord: React.FC = () => {
return (
<Formik
initialValues={{
enableDiscord: !!data?.notificationTypes.discord,
discordId: data?.discordId,
types:
(data?.discordEnabledTypes ?? 0) &
(data?.notificationTypes.discord ?? 0),
}}
validationSchema={UserNotificationsDiscordSchema}
enableReinitialize
@ -64,7 +65,7 @@ const UserNotificationsDiscord: React.FC = () => {
telegramChatId: data?.telegramChatId,
telegramSendSilently: data?.telegramSendSilently,
notificationTypes: {
discord: values.enableDiscord ? ALL_NOTIFICATIONS : 0,
discord: values.types,
},
});
addToast(intl.formatMessage(messages.discordsettingssaved), {
@ -81,27 +82,23 @@ const UserNotificationsDiscord: React.FC = () => {
}
}}
>
{({ errors, touched, isSubmitting, isValid }) => {
{({
errors,
touched,
isSubmitting,
isValid,
values,
setFieldValue,
setFieldTouched,
}) => {
return (
<Form className="section">
{data?.discordEnabled && (
<div className="form-row">
<label htmlFor="enableDiscord" className="checkbox-label">
{intl.formatMessage(messages.enableDiscord)}
</label>
<div className="form-input">
<Field
type="checkbox"
id="enableDiscord"
name="enableDiscord"
/>
</div>
</div>
)}
<div className="form-row">
<label htmlFor="discordId" className="text-label">
<span>{intl.formatMessage(messages.discordId)}</span>
<span className="label-required">*</span>
{intl.formatMessage(messages.discordId)}
{!!data?.discordEnabledTypes && (
<span className="label-required">*</span>
)}
<span className="label-tip">
{intl.formatMessage(messages.discordIdTip, {
FindDiscordIdLink: function FindDiscordIdLink(msg) {
@ -127,6 +124,20 @@ const UserNotificationsDiscord: React.FC = () => {
)}
</div>
</div>
<NotificationTypeSelector
user={user}
enabledTypes={data?.discordEnabledTypes ?? 0}
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="inline-flex ml-3 rounded-md shadow-sm">

@ -1,5 +1,5 @@
import axios from 'axios';
import { Field, Form, Formik } from 'formik';
import { Form, Formik } from 'formik';
import { useRouter } from 'next/router';
import React from 'react';
import { defineMessages, useIntl } from 'react-intl';
@ -12,13 +12,15 @@ import globalMessages from '../../../../i18n/globalMessages';
import Badge from '../../../Common/Badge';
import Button from '../../../Common/Button';
import LoadingSpinner from '../../../Common/LoadingSpinner';
import { ALL_NOTIFICATIONS } from '../../../NotificationTypeSelector';
import SensitiveInput from '../../../Common/SensitiveInput';
import NotificationTypeSelector, {
ALL_NOTIFICATIONS,
} from '../../../NotificationTypeSelector';
import { OpenPgpLink } from '../../../Settings/Notifications/NotificationsEmail';
const messages = defineMessages({
emailsettingssaved: 'Email notification settings saved successfully!',
emailsettingsfailed: 'Email notification settings failed to save.',
enableEmail: 'Enable Notifications',
pgpPublicKey: 'PGP Public Key',
pgpPublicKeyTip:
'Encrypt email messages using <OpenPgpLink>OpenPGP</OpenPgpLink>',
@ -50,8 +52,8 @@ const UserEmailSettings: React.FC = () => {
return (
<Formik
initialValues={{
enableEmail: !!(data?.notificationTypes.email ?? true),
pgpKey: data?.pgpKey,
types: data?.notificationTypes.email ?? ALL_NOTIFICATIONS,
}}
validationSchema={UserNotificationsEmailSchema}
enableReinitialize
@ -63,7 +65,7 @@ const UserEmailSettings: React.FC = () => {
telegramChatId: data?.telegramChatId,
telegramSendSilently: data?.telegramSendSilently,
notificationTypes: {
email: values.enableEmail ? ALL_NOTIFICATIONS : 0,
email: values.types,
},
});
addToast(intl.formatMessage(messages.emailsettingssaved), {
@ -80,17 +82,17 @@ const UserEmailSettings: React.FC = () => {
}
}}
>
{({ errors, touched, isSubmitting, isValid }) => {
{({
errors,
touched,
isSubmitting,
isValid,
values,
setFieldValue,
setFieldTouched,
}) => {
return (
<Form className="section">
<div className="form-row">
<label htmlFor="enableEmail" className="checkbox-label">
{intl.formatMessage(messages.enableEmail)}
</label>
<div className="form-input">
<Field type="checkbox" id="enableEmail" name="enableEmail" />
</div>
</div>
<div className="form-row">
<label htmlFor="pgpKey" className="text-label">
<span className="mr-2">
@ -107,8 +109,9 @@ const UserEmailSettings: React.FC = () => {
</label>
<div className="form-input">
<div className="form-input-field">
<Field
as="textarea"
<SensitiveInput
as="field"
type="textarea"
id="pgpKey"
name="pgpKey"
rows="10"
@ -120,6 +123,19 @@ const UserEmailSettings: React.FC = () => {
)}
</div>
</div>
<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="inline-flex ml-3 rounded-md shadow-sm">

@ -11,12 +11,11 @@ import { useUser } from '../../../../hooks/useUser';
import globalMessages from '../../../../i18n/globalMessages';
import Button from '../../../Common/Button';
import LoadingSpinner from '../../../Common/LoadingSpinner';
import { ALL_NOTIFICATIONS } from '../../../NotificationTypeSelector';
import NotificationTypeSelector from '../../../NotificationTypeSelector';
const messages = defineMessages({
telegramsettingssaved: 'Telegram notification settings saved successfully!',
telegramsettingsfailed: 'Telegram notification settings failed to save.',
enableTelegram: 'Enable Notifications',
telegramChatId: 'Chat ID',
telegramChatIdTipLong:
'<TelegramBotLink>Start a chat</TelegramBotLink>, add <GetIdBotLink>@get_id_bot</GetIdBotLink>, and issue the <code>/my_id</code> command',
@ -36,8 +35,8 @@ const UserTelegramSettings: React.FC = () => {
const UserNotificationsTelegramSchema = Yup.object().shape({
telegramChatId: Yup.string()
.when('enableTelegram', {
is: true,
.when('types', {
is: (value: unknown) => !!value,
then: Yup.string()
.nullable()
.required(intl.formatMessage(messages.validationTelegramChatId)),
@ -56,9 +55,9 @@ const UserTelegramSettings: React.FC = () => {
return (
<Formik
initialValues={{
enableTelegram: !!data?.notificationTypes.telegram,
telegramChatId: data?.telegramChatId,
telegramSendSilently: data?.telegramSendSilently,
types: data?.notificationTypes.telegram ?? 0,
}}
validationSchema={UserNotificationsTelegramSchema}
enableReinitialize
@ -70,7 +69,7 @@ const UserTelegramSettings: React.FC = () => {
telegramChatId: values.telegramChatId,
telegramSendSilently: values.telegramSendSilently,
notificationTypes: {
telegram: values.enableTelegram ? ALL_NOTIFICATIONS : 0,
telegram: values.types,
},
});
addToast(intl.formatMessage(messages.telegramsettingssaved), {
@ -87,21 +86,17 @@ const UserTelegramSettings: React.FC = () => {
}
}}
>
{({ errors, touched, isSubmitting, isValid }) => {
{({
errors,
touched,
isSubmitting,
isValid,
values,
setFieldValue,
setFieldTouched,
}) => {
return (
<Form className="section">
<div className="form-row">
<label htmlFor="enableTelegram" className="checkbox-label">
{intl.formatMessage(messages.enableTelegram)}
</label>
<div className="form-input">
<Field
type="checkbox"
id="enableTelegram"
name="enableTelegram"
/>
</div>
</div>
<div className="form-row">
<label htmlFor="telegramChatId" className="text-label">
{intl.formatMessage(messages.telegramChatId)}
@ -166,6 +161,19 @@ const UserTelegramSettings: React.FC = () => {
/>
</div>
</div>
<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="inline-flex ml-3 rounded-md shadow-sm">

@ -1,5 +1,5 @@
import axios from 'axios';
import { Field, Form, Formik } from 'formik';
import { Form, Formik } from 'formik';
import { useRouter } from 'next/router';
import React from 'react';
import { defineMessages, useIntl } from 'react-intl';
@ -10,12 +10,13 @@ import { useUser } from '../../../../hooks/useUser';
import globalMessages from '../../../../i18n/globalMessages';
import Button from '../../../Common/Button';
import LoadingSpinner from '../../../Common/LoadingSpinner';
import { ALL_NOTIFICATIONS } from '../../../NotificationTypeSelector';
import NotificationTypeSelector, {
ALL_NOTIFICATIONS,
} from '../../../NotificationTypeSelector';
const messages = defineMessages({
webpushsettingssaved: 'Web push notification settings saved successfully!',
webpushsettingsfailed: 'Web push notification settings failed to save.',
enableWebPush: 'Enable Notifications',
});
const UserWebPushSettings: React.FC = () => {
@ -34,18 +35,18 @@ const UserWebPushSettings: React.FC = () => {
return (
<Formik
initialValues={{
enableWebPush: !!(data?.notificationTypes.webpush ?? true),
pgpKey: data?.pgpKey,
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,
telegramChatId: data?.telegramChatId,
telegramSendSilently: data?.telegramSendSilently,
notificationTypes: {
webpush: values.enableWebPush ? ALL_NOTIFICATIONS : 0,
webpush: values.types,
},
});
mutate('/api/v1/settings/public');
@ -63,21 +64,30 @@ const UserWebPushSettings: React.FC = () => {
}
}}
>
{({ isSubmitting, isValid }) => {
{({
errors,
touched,
isSubmitting,
isValid,
values,
setFieldValue,
setFieldTouched,
}) => {
return (
<Form className="section">
<div className="form-row">
<label htmlFor="enableEmail" className="checkbox-label">
{intl.formatMessage(messages.enableWebPush)}
</label>
<div className="form-input">
<Field
type="checkbox"
id="enableWebPush"
name="enableWebPush"
/>
</div>
</div>
<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="inline-flex ml-3 rounded-md shadow-sm">

@ -42,6 +42,18 @@ const UserNotificationSettings: React.FC = ({ children }) => {
regex: /\/settings\/notifications\/email/,
hidden: !data?.emailEnabled,
},
{
text: intl.formatMessage(messages.webpush),
content: (
<span className="flex items-center">
<CloudIcon className="h-4 mr-2" />
{intl.formatMessage(messages.webpush)}
</span>
),
route: '/settings/notifications/webpush',
regex: /\/settings\/notifications\/webpush/,
hidden: !data?.webPushEnabled,
},
{
text: 'Discord',
content: (
@ -65,18 +77,6 @@ const UserNotificationSettings: React.FC = ({ children }) => {
regex: /\/settings\/notifications\/telegram/,
hidden: !data?.telegramEnabled || !data?.telegramBotUsername,
},
{
text: intl.formatMessage(messages.webpush),
content: (
<span className="flex items-center">
<CloudIcon className="h-4 mr-2" />
{intl.formatMessage(messages.webpush)}
</span>
),
route: '/settings/notifications/webpush',
regex: /\/settings\/notifications\/webpush/,
hidden: !data?.webPushEnabled,
},
];
settingsRoutes.forEach((settingsRoute) => {

@ -65,6 +65,8 @@ const UserSettings: React.FC = ({ children }) => {
text: intl.formatMessage(messages.menuNotifications),
route: data?.emailEnabled
? '/settings/notifications/email'
: data?.webPushEnabled
? '/settings/notifications/webpush'
: '/settings/notifications/discord',
regex: /\/settings\/notifications/,
},

@ -89,18 +89,24 @@
"components.MovieDetails.viewfullcrew": "View Full Crew",
"components.MovieDetails.watchtrailer": "Watch Trailer",
"components.NotificationTypeSelector.mediaAutoApproved": "Media Automatically Approved",
"components.NotificationTypeSelector.mediaAutoApprovedDescription": "Sends a notification when requested media is automatically approved.",
"components.NotificationTypeSelector.mediaAutoApprovedDescription": "Send notifications when users submit new media requests which are automatically approved.",
"components.NotificationTypeSelector.mediaapproved": "Media Approved",
"components.NotificationTypeSelector.mediaapprovedDescription": "Sends a notification when requested media is manually approved.",
"components.NotificationTypeSelector.mediaapprovedDescription": "Send notifications when media requests are manually approved.",
"components.NotificationTypeSelector.mediaavailable": "Media Available",
"components.NotificationTypeSelector.mediaavailableDescription": "Sends a notification when requested media becomes available.",
"components.NotificationTypeSelector.mediaavailableDescription": "Send notifications when media requests become available.",
"components.NotificationTypeSelector.mediadeclined": "Media Declined",
"components.NotificationTypeSelector.mediadeclinedDescription": "Sends a notification when a media request is declined.",
"components.NotificationTypeSelector.mediadeclinedDescription": "Send notifications when media requests are declined.",
"components.NotificationTypeSelector.mediafailed": "Media Failed",
"components.NotificationTypeSelector.mediafailedDescription": "Sends a notification when requested media fails to be added to Radarr or Sonarr.",
"components.NotificationTypeSelector.mediafailedDescription": "Send notifications when media requests fail to be added to Radarr or Sonarr.",
"components.NotificationTypeSelector.mediarequested": "Media Requested",
"components.NotificationTypeSelector.mediarequestedDescription": "Sends a notification when media is requested and requires approval.",
"components.NotificationTypeSelector.mediarequestedDescription": "Send notifications when users submit new media requests which require approval.",
"components.NotificationTypeSelector.notificationTypes": "Notification Types",
"components.NotificationTypeSelector.usermediaAutoApprovedDescription": "Get notified when other users submit new media requests which are automatically approved.",
"components.NotificationTypeSelector.usermediaapprovedDescription": "Get notified when your media requests are approved.",
"components.NotificationTypeSelector.usermediaavailableDescription": "Get notified when your media requests become available.",
"components.NotificationTypeSelector.usermediadeclinedDescription": "Get notified when your media requests are declined.",
"components.NotificationTypeSelector.usermediafailedDescription": "Get notified when media requests fail to be added to Radarr or Sonarr.",
"components.NotificationTypeSelector.usermediarequestedDescription": "Get notified when other users submit new media requests which require approval.",
"components.PermissionEdit.admin": "Admin",
"components.PermissionEdit.adminDescription": "Full administrator access. Bypasses all other permission checks.",
"components.PermissionEdit.advancedrequest": "Advanced Requests",
@ -261,6 +267,7 @@
"components.Settings.Notifications.NotificationsLunaSea.toastLunaSeaTestFailed": "LunaSea test notification failed to send.",
"components.Settings.Notifications.NotificationsLunaSea.toastLunaSeaTestSending": "Sending LunaSea test notification…",
"components.Settings.Notifications.NotificationsLunaSea.toastLunaSeaTestSuccess": "LunaSea test notification sent!",
"components.Settings.Notifications.NotificationsLunaSea.validationTypes": "You must select at least one notification type",
"components.Settings.Notifications.NotificationsLunaSea.validationWebhookUrl": "You must provide a valid URL",
"components.Settings.Notifications.NotificationsLunaSea.webhookUrl": "Webhook URL",
"components.Settings.Notifications.NotificationsLunaSea.webhookUrlTip": "Your user- or device-based <LunaSeaLink>notification webhook URL</LunaSeaLink>",
@ -273,6 +280,7 @@
"components.Settings.Notifications.NotificationsPushbullet.toastPushbulletTestSending": "Sending Pushbullet test notification…",
"components.Settings.Notifications.NotificationsPushbullet.toastPushbulletTestSuccess": "Pushbullet test notification sent!",
"components.Settings.Notifications.NotificationsPushbullet.validationAccessTokenRequired": "You must provide an access token",
"components.Settings.Notifications.NotificationsPushbullet.validationTypes": "You must select at least one notification type",
"components.Settings.Notifications.NotificationsPushover.accessToken": "Application API Token",
"components.Settings.Notifications.NotificationsPushover.accessTokenTip": "<ApplicationRegistrationLink>Register an application</ApplicationRegistrationLink> for use with Overseerr",
"components.Settings.Notifications.NotificationsPushover.agentenabled": "Enable Agent",
@ -284,13 +292,15 @@
"components.Settings.Notifications.NotificationsPushover.userToken": "User or Group Key",
"components.Settings.Notifications.NotificationsPushover.userTokenTip": "Your 30-character <UsersGroupsLink>user or group identifier</UsersGroupsLink>",
"components.Settings.Notifications.NotificationsPushover.validationAccessTokenRequired": "You must provide a valid application token",
"components.Settings.Notifications.NotificationsPushover.validationUserTokenRequired": "You must provide a valid user key",
"components.Settings.Notifications.NotificationsPushover.validationTypes": "You must select at least one notification type",
"components.Settings.Notifications.NotificationsPushover.validationUserTokenRequired": "You must provide a valid user or group key",
"components.Settings.Notifications.NotificationsSlack.agentenabled": "Enable Agent",
"components.Settings.Notifications.NotificationsSlack.slacksettingsfailed": "Slack notification settings failed to save.",
"components.Settings.Notifications.NotificationsSlack.slacksettingssaved": "Slack notification settings saved successfully!",
"components.Settings.Notifications.NotificationsSlack.toastSlackTestFailed": "Slack test notification failed to send.",
"components.Settings.Notifications.NotificationsSlack.toastSlackTestSending": "Sending Slack test notification…",
"components.Settings.Notifications.NotificationsSlack.toastSlackTestSuccess": "Slack test notification sent!",
"components.Settings.Notifications.NotificationsSlack.validationTypes": "You must select at least one notification type",
"components.Settings.Notifications.NotificationsSlack.validationWebhookUrl": "You must provide a valid URL",
"components.Settings.Notifications.NotificationsSlack.webhookUrl": "Webhook URL",
"components.Settings.Notifications.NotificationsSlack.webhookUrlTip": "Create an <WebhookLink>Incoming Webhook</WebhookLink> integration",
@ -311,6 +321,7 @@
"components.Settings.Notifications.NotificationsWebhook.toastWebhookTestSending": "Sending webhook test notification…",
"components.Settings.Notifications.NotificationsWebhook.toastWebhookTestSuccess": "Webhook test notification sent!",
"components.Settings.Notifications.NotificationsWebhook.validationJsonPayloadRequired": "You must provide a valid JSON payload",
"components.Settings.Notifications.NotificationsWebhook.validationTypes": "You must select at least one notification type",
"components.Settings.Notifications.NotificationsWebhook.validationWebhookUrl": "You must provide a valid URL",
"components.Settings.Notifications.NotificationsWebhook.webhookUrl": "Webhook URL",
"components.Settings.Notifications.NotificationsWebhook.webhooksettingsfailed": "Webhook notification settings failed to save.",
@ -328,8 +339,6 @@
"components.Settings.Notifications.chatIdTip": "Start a chat with your bot, add <GetIdBotLink>@get_id_bot</GetIdBotLink>, and issue the <code>/my_id</code> command",
"components.Settings.Notifications.discordsettingsfailed": "Discord notification settings failed to save.",
"components.Settings.Notifications.discordsettingssaved": "Discord notification settings saved successfully!",
"components.Settings.Notifications.emailNotificationTypesAlertDescription": "<strong>Media Requested</strong>, <strong>Media Automatically Approved</strong>, and <strong>Media Failed</strong> email notifications are sent to all users with the <strong>Manage Requests</strong> permission.",
"components.Settings.Notifications.emailNotificationTypesAlertDescriptionPt2": "<strong>Media Approved</strong>, <strong>Media Declined</strong>, and <strong>Media Available</strong> email notifications are sent to the user who submitted the request.",
"components.Settings.Notifications.emailsender": "Sender Address",
"components.Settings.Notifications.emailsettingsfailed": "Email notification settings failed to save.",
"components.Settings.Notifications.emailsettingssaved": "Email notification settings saved successfully!",
@ -362,10 +371,11 @@
"components.Settings.Notifications.validationBotAPIRequired": "You must provide a bot authorization token",
"components.Settings.Notifications.validationChatIdRequired": "You must provide a valid chat ID",
"components.Settings.Notifications.validationEmail": "You must provide a valid email address",
"components.Settings.Notifications.validationPgpPassword": "You must provide a PGP password if a PGP private key is entered",
"components.Settings.Notifications.validationPgpPrivateKey": "You must provide a valid PGP private key if a PGP password is entered",
"components.Settings.Notifications.validationPgpPassword": "You must provide a PGP password",
"components.Settings.Notifications.validationPgpPrivateKey": "You must provide a valid PGP private key",
"components.Settings.Notifications.validationSmtpHostRequired": "You must provide a valid hostname or IP address",
"components.Settings.Notifications.validationSmtpPortRequired": "You must provide a valid port number",
"components.Settings.Notifications.validationTypes": "You must select at least one notification type",
"components.Settings.Notifications.validationUrl": "You must provide a valid URL",
"components.Settings.Notifications.webhookUrl": "Webhook URL",
"components.Settings.Notifications.webhookUrlTip": "Create a <DiscordWebhookLink>webhook integration</DiscordWebhookLink> in your server",
@ -765,10 +775,6 @@
"components.UserProfile.UserSettings.UserNotificationSettings.email": "Email",
"components.UserProfile.UserSettings.UserNotificationSettings.emailsettingsfailed": "Email notification settings failed to save.",
"components.UserProfile.UserSettings.UserNotificationSettings.emailsettingssaved": "Email notification settings saved successfully!",
"components.UserProfile.UserSettings.UserNotificationSettings.enableDiscord": "Enable Mentions",
"components.UserProfile.UserSettings.UserNotificationSettings.enableEmail": "Enable Notifications",
"components.UserProfile.UserSettings.UserNotificationSettings.enableTelegram": "Enable Notifications",
"components.UserProfile.UserSettings.UserNotificationSettings.enableWebPush": "Enable Notifications",
"components.UserProfile.UserSettings.UserNotificationSettings.notifications": "Notifications",
"components.UserProfile.UserSettings.UserNotificationSettings.notificationsettings": "Notification Settings",
"components.UserProfile.UserSettings.UserNotificationSettings.pgpPublicKey": "PGP Public Key",

Loading…
Cancel
Save