diff --git a/components/Ban.vue b/components/Ban.vue index d6d008f43..032b9760c 100644 --- a/components/Ban.vue +++ b/components/Ban.vue @@ -88,6 +88,46 @@ +
+ + + Mod messages + +
+
+ + Mod messages +
+

+ You can use this feature to warn a user without banning them, etc. +

+ + + + + + + + + + + + + + + +
Sent onSent byMessage
{{$datetime($ulidTime(message.id))}} + + @{{message.adminUsername}} + +
+ + +
+
@@ -109,6 +149,10 @@ showBanForm: !!this.user.bannedReason, isBanned: !!this.user.bannedReason, + showMessages: false, + messages: undefined, + message: '', + saving: false, forbidden, @@ -124,6 +168,10 @@ if (this.banProposals.length > 0) { this.showBanForm = true; } + this.messages = await this.$axios.$get(`/admin/mod-messages/${encodeURIComponent(this.user.username)}`); + if (this.messages.length > 0) { + this.showMessages = true; + } }, methods: { async ban() { @@ -166,6 +214,22 @@ this.user.bannedReason = proposal.bannedReason; this.user.bannedTerms = proposal.bannedTerms.split(','); }, + async sendMessage() { + if (!this.message) { return; } + await this.$confirm(`Please proof-read and confirm sending:

${this.nl2br(this.message)}
`, 'danger'); + this.saving = true; + try { + this.messages = await this.$post(`/admin/mod-message/${encodeURIComponent(this.user.username)}`, { + message: this.message, + }); + this.message = ''; + } finally { + this.saving = false; + } + }, + nl2br(text) { + return text.replace(new RegExp('\\n', 'g'), '
'); + } }, computed: { canApplyBan() { diff --git a/locale/_base/translations.suml b/locale/_base/translations.suml index 23ab4df07..aeceb63e7 100644 --- a/locale/_base/translations.suml +++ b/locale/_base/translations.suml @@ -583,6 +583,10 @@ user: qr: header: 'QR code' download: 'Download QR code' + modMessage: + subject: 'A message from a moderator' + intro: 'Hi! One of our moderators has sent you the following message:' + respond: 'If you want to respond, you can simply reply to this email.' profile: description: 'Description' diff --git a/locale/en/translations.suml b/locale/en/translations.suml index f8e9c8f2d..a23131cc8 100644 --- a/locale/en/translations.suml +++ b/locale/en/translations.suml @@ -693,6 +693,10 @@ user: qr: header: 'QR code' download: 'Download QR code' + modMessage: + subject: 'A message from a moderator' + intro: 'Hi! One of our moderators has sent you the following message:' + respond: 'If you want to respond, you can simply reply to this email.' profile: description: 'Description' diff --git a/locale/pl/translations.suml b/locale/pl/translations.suml index 039e79399..13ee5fbe3 100644 --- a/locale/pl/translations.suml +++ b/locale/pl/translations.suml @@ -1318,6 +1318,10 @@ user: qr: header: 'Kod QR' download: 'Pobierz kod QR' + modMessage: + subject: 'Wiadomość od osoby moderującej' + intro: 'Hej! Jedna z naszych osób moderujących wysłała Ci następującą wiadomość:' + respond: 'Jeśli chcesz odpowiedzieć, możesz po prostu odpisać na tego maila.' profile: description: 'Opis' diff --git a/migrations/062-user-messages.sql b/migrations/062-user-messages.sql new file mode 100644 index 000000000..daf016178 --- /dev/null +++ b/migrations/062-user-messages.sql @@ -0,0 +1,11 @@ +-- Up + +CREATE TABLE user_messages ( + id TEXT NOT NULL PRIMARY KEY, + userId TEXT NOT NULL, + adminId TEXT NOT NULL, + message TEXT NOT NULL +); + +-- Down + diff --git a/server/routes/admin.js b/server/routes/admin.js index e0a36d43f..eb6bd8dcb 100644 --- a/server/routes/admin.js +++ b/server/routes/admin.js @@ -371,6 +371,60 @@ router.post('/admin/reports/handle/:id', handleErrorAsync(async (req, res) => { return res.json(true); })); +const fetchModMessages = async (db, user) => { + return db.all(SQL` + SELECT m.id, a.username as adminUsername, m.message + FROM user_messages m + LEFT JOIN users a ON m.adminId = a.id + WHERE m.userId = ${user.id} + `) +} + +router.post('/admin/mod-message/:username', handleErrorAsync(async (req, res) => { + if (!req.isGranted('users')) { + return res.status(401).json({error: 'Unauthorised'}); + } + + if (!req.body.message) { + return res.status(400).json({error: 'Bad request'}); + } + + const user = await fetchUserByUsername(req.db, req.params.username); + + if (!user) { + return res.status(400).json({error: 'No such user'}); + } + + await req.db.get(SQL`INSERT INTO user_messages (id, userId, adminId, message) VALUES ( + ${ulid()}, + ${user.id}, + ${req.user.id}, + ${req.body.message} + )`); + + mailer(user.email, 'modMessage', { + message: req.body.message, + username: req.params.username, + modUsername: req.user.username, + }); + + return res.json(await fetchModMessages(req.db, user)); +})); + +router.get('/admin/mod-messages/:username', handleErrorAsync(async (req, res) => { + if (!req.isGranted('users')) { + return res.status(401).json({error: 'Unauthorised'}); + } + + const user = await fetchUserByUsername(req.db, req.params.username); + + if (!user) { + return res.status(400).json({error: 'No such user'}); + } + + return res.json(await fetchModMessages(req.db, user)); +})); + router.get('/admin/moderation', handleErrorAsync(async (req, res) => { if (!req.isGranted('users')) { return res.status(401).json({error: 'Unauthorised'}); diff --git a/src/mailer.js b/src/mailer.js index e21fd4b67..538e83d02 100644 --- a/src/mailer.js +++ b/src/mailer.js @@ -89,6 +89,16 @@ const templates = { text: 'Check them out here: https://[[domain]]/admin', html: '

Check them out here: [[domain]]/admin

', }, + modMessage: { + subject: '[[user.modMessage.subject]]', + text: `[[user.modMessage.intro]]\n\n{{nl2br:message}}\n\n[[user.modMessage.respond]]`, + html: ` +

[[user.modMessage.intro]]

+

{{nl2br:message}}

+

[[user.modMessage.respond]]

+

@{{modUsername}} → @{{username}}

+ `, + }, } const applyTemplate = (template, context, params) => { @@ -122,6 +132,12 @@ const applyTemplate = (template, context, params) => { : Object.keys(value).map(s => ` - ${s}: ${value[s]}`).join('\n'); } } + if (key.startsWith('nl2br:')) { + const value = params[key.substring(6)]; + return context === 'html' + ? value.replace(new RegExp('\\n', 'g'), '
') + : value; + } return params[key]; });