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.
+
+
+
+
+
+
@@ -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];
});