mirror of
https://gitlab.com/PronounsPage/PronounsPage.git
synced 2025-09-19 12:36:07 -04:00
[user] require multiple mods for a ban
This commit is contained in:
parent
023da6e0d5
commit
9e2d8263df
@ -30,6 +30,42 @@
|
|||||||
<T>ban.action</T>
|
<T>ban.action</T>
|
||||||
</a>
|
</a>
|
||||||
<div v-else>
|
<div v-else>
|
||||||
|
<div class="alert alert-warning">
|
||||||
|
<h5>Ban proposals</h5>
|
||||||
|
<p>
|
||||||
|
After at least 3 moderators had proposed bans,
|
||||||
|
you'll be able to pick one of the proposals in order to actually issue the ban.
|
||||||
|
If the proposed reasons/term points significantly differ
|
||||||
|
or if you think the person shouldn't be banned despite another moderator thinking otherwise,
|
||||||
|
please start a thread on Teams to discuss it.
|
||||||
|
</p>
|
||||||
|
<table class="table table-striped">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Proposed at</th>
|
||||||
|
<th>Proposed by</th>
|
||||||
|
<th>Reason</th>
|
||||||
|
<th>Terms</th>
|
||||||
|
<th>Action</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="proposal in banProposals">
|
||||||
|
<td>{{$datetime($ulidTime(proposal.id))}}</td>
|
||||||
|
<td class="small">
|
||||||
|
(TODO)<br/>
|
||||||
|
<span style="font-size: 0.5em">{{proposal.bannedBy}}</span>
|
||||||
|
</td>
|
||||||
|
<td>{{proposal.bannedReason}}</td>
|
||||||
|
<td><ul><li v-for="term in proposal.bannedTerms.split(',')">{{term}}</li></ul></td>
|
||||||
|
<td>
|
||||||
|
<button v-if="canApplyBan" class="btn btn-outline-danger btn-sm" @click="applyBan(proposal.id)">Apply ban</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<button v-if="isBanned && $isGranted('*')" class="btn btn-success btn-sm" @click="applyBan(0)">Unban</button>
|
||||||
|
</div>
|
||||||
<textarea v-model="user.bannedReason" class="form-control" rows="3" :placeholder="$t('ban.reason') + ' ' + $t('ban.visible')" :disabled="saving"></textarea>
|
<textarea v-model="user.bannedReason" class="form-control" rows="3" :placeholder="$t('ban.reason') + ' ' + $t('ban.visible')" :disabled="saving"></textarea>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<p class="my-1"><label><strong><T>ban.terms</T><T>quotation.colon</T></strong></label></p>
|
<p class="my-1"><label><strong><T>ban.terms</T><T>quotation.colon</T></strong></label></p>
|
||||||
@ -69,24 +105,30 @@
|
|||||||
reported: false,
|
reported: false,
|
||||||
|
|
||||||
showBanForm: !!this.user.bannedReason,
|
showBanForm: !!this.user.bannedReason,
|
||||||
|
isBanned: !!this.user.bannedReason,
|
||||||
|
|
||||||
saving: false,
|
saving: false,
|
||||||
|
|
||||||
forbidden,
|
forbidden,
|
||||||
|
|
||||||
abuseReports: [],
|
abuseReports: [],
|
||||||
|
banProposals: [],
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async mounted() {
|
async mounted() {
|
||||||
if (!this.$isGranted('users')) { return; }
|
if (!this.$isGranted('users')) { return; }
|
||||||
this.abuseReports = await this.$axios.$get(`/admin/reports/${this.user.id}`);
|
this.abuseReports = await this.$axios.$get(`/admin/reports/${this.user.id}`);
|
||||||
|
this.banProposals = await this.$axios.$get(`/admin/ban-proposals/${encodeURIComponent(this.user.username)}`);
|
||||||
|
if (this.banProposals.length > 0) {
|
||||||
|
this.showBanForm = true;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
async ban() {
|
async ban() {
|
||||||
await this.$confirm(this.$t('ban.confirm', {username: this.user.username}), 'danger');
|
await this.$confirm(this.$t('ban.confirm', {username: this.user.username}), 'danger');
|
||||||
this.saving = true;
|
this.saving = true;
|
||||||
try {
|
try {
|
||||||
await this.$post(`/admin/ban/${encodeURIComponent(this.user.username)}`, {
|
await this.$post(`/admin/propose-ban/${encodeURIComponent(this.user.username)}`, {
|
||||||
reason: this.user.bannedReason,
|
reason: this.user.bannedReason,
|
||||||
terms: this.user.bannedTerms,
|
terms: this.user.bannedTerms,
|
||||||
});
|
});
|
||||||
@ -95,6 +137,16 @@
|
|||||||
this.saving = false;
|
this.saving = false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
async applyBan(proposalId) {
|
||||||
|
await this.$confirm(this.$t('ban.confirm', {username: this.user.username}), 'danger');
|
||||||
|
this.saving = true;
|
||||||
|
try {
|
||||||
|
await this.$post(`/admin/apply-ban/${encodeURIComponent(this.user.username)}/${proposalId}`);
|
||||||
|
window.location.reload();
|
||||||
|
} finally {
|
||||||
|
this.saving = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
async report() {
|
async report() {
|
||||||
if (!this.reportComment) { return; }
|
if (!this.reportComment) { return; }
|
||||||
await this.$confirm(this.$t('report.confirm', {username: this.user.username}), 'danger');
|
await this.$confirm(this.$t('report.confirm', {username: this.user.username}), 'danger');
|
||||||
@ -109,5 +161,10 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
computed: {
|
||||||
|
canApplyBan() {
|
||||||
|
return !this.isBanned || this.$isGranted('users') && (this.banProposals.length >= 3 || this.$isGranted('*'));
|
||||||
|
},
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
14
migrations/059-ban-proposals.sql
Normal file
14
migrations/059-ban-proposals.sql
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
-- Up
|
||||||
|
|
||||||
|
CREATE TABLE ban_proposals (
|
||||||
|
id TEXT NOT NULL PRIMARY KEY,
|
||||||
|
userId TEXT NULL NOT NULL REFERENCES users ON DELETE CASCADE,
|
||||||
|
bannedBy TEXT NOT NULL REFERENCES users ON DELETE SET NULL,
|
||||||
|
bannedTerms TEXT NULL NOT NULL,
|
||||||
|
bannedReason TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX "ban_proposals_userId" ON "ban_proposals" ("userId");
|
||||||
|
|
||||||
|
-- Down
|
||||||
|
|
@ -221,6 +221,41 @@
|
|||||||
</Loading>
|
</Loading>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<section v-if="$isGranted('users')">
|
||||||
|
<h3>
|
||||||
|
<Icon v="ban"/>
|
||||||
|
Pending bans
|
||||||
|
({{banProposals ? banProposals.length : 0}})
|
||||||
|
</h3>
|
||||||
|
<Loading :value="banProposals">
|
||||||
|
<table class="table table-striped">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>User</th>
|
||||||
|
<th>Votes</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="proposal in banProposals">
|
||||||
|
<td>
|
||||||
|
<a :href="`https://pronouns.page/@${proposal.username}`" target="_blank" rel="noopener">@{{proposal.username}}</a>
|
||||||
|
<ul class="list-unstyled">
|
||||||
|
<li v-for="locale in proposal.profiles.split(',')" v-if="locales[locale]">
|
||||||
|
<LocaleLink :link="`/@${proposal.username}`" :locale="locale">
|
||||||
|
{{ locales[locale].name }}
|
||||||
|
</LocaleLink>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{{proposal.votes}}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</Loading>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section v-if="$isGranted('users')">
|
<section v-if="$isGranted('users')">
|
||||||
<h3>
|
<h3>
|
||||||
<Icon v="siren-on"/>
|
<Icon v="siren-on"/>
|
||||||
@ -306,6 +341,7 @@ import {deepSet, head} from "../src/helpers";
|
|||||||
missingTranslations: translator.listMissingTranslations(),
|
missingTranslations: translator.listMissingTranslations(),
|
||||||
abuseReports: undefined,
|
abuseReports: undefined,
|
||||||
translationProposals: undefined,
|
translationProposals: undefined,
|
||||||
|
banProposals: undefined,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async asyncData({ app, store }) {
|
async asyncData({ app, store }) {
|
||||||
@ -326,6 +362,10 @@ import {deepSet, head} from "../src/helpers";
|
|||||||
this.$axios.$get(`/translations/proposals`)
|
this.$axios.$get(`/translations/proposals`)
|
||||||
.then(r => this.translationProposals = r)
|
.then(r => this.translationProposals = r)
|
||||||
.catch();
|
.catch();
|
||||||
|
|
||||||
|
this.$axios.$get(`/admin/ban-proposals`)
|
||||||
|
.then(r => this.banProposals = r)
|
||||||
|
.catch();
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
async impersonate(email) {
|
async impersonate(email) {
|
||||||
|
@ -12,7 +12,7 @@ import buildLocaleList from "../../src/buildLocaleList";
|
|||||||
import {archiveBan, liftBan} from "../ban";
|
import {archiveBan, liftBan} from "../ban";
|
||||||
import marked from 'marked';
|
import marked from 'marked';
|
||||||
import {loadCurrentUser} from "./user";
|
import {loadCurrentUser} from "./user";
|
||||||
import {encodeTime} from "ulid";
|
import {encodeTime, ulid} from "ulid";
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
@ -194,12 +194,54 @@ router.get('/admin/stats-public', handleErrorAsync(async (req, res) => {
|
|||||||
|
|
||||||
const normalise = s => s.trim().toLowerCase();
|
const normalise = s => s.trim().toLowerCase();
|
||||||
|
|
||||||
router.post('/admin/ban/:username', handleErrorAsync(async (req, res) => {
|
const fetchUserByUsername = async (db, username) => {
|
||||||
|
return await db.get(SQL`SELECT id, email FROM users WHERE usernameNorm = ${normalise(username)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchBanProposals = async (db, userId) => {
|
||||||
|
return await db.all(SQL`
|
||||||
|
SELECT * FROM ban_proposals WHERE userId = ${userId}
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
router.get('/admin/ban-proposals', handleErrorAsync(async (req, res) => {
|
||||||
if (!req.isGranted('users')) {
|
if (!req.isGranted('users')) {
|
||||||
return res.status(401).json({error: 'Unauthorised'});
|
return res.status(401).json({error: 'Unauthorised'});
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = await req.db.get(SQL`SELECT id, email FROM users WHERE usernameNorm = ${normalise(req.params.username)}`);
|
const cutoff = encodeTime(Date.now() - 3*31*24*60*60*1000, 10) + '0'.repeat(16);
|
||||||
|
|
||||||
|
return res.json(await req.db.all(SQL`
|
||||||
|
SELECT u.username, group_concat(p.locale) as profiles, count(bp.id) / count(p.locale) as votes
|
||||||
|
FROM ban_proposals bp
|
||||||
|
LEFT JOIN users u ON bp.userId = u.id
|
||||||
|
LEFT JOIN profiles p on u.id = p.userId
|
||||||
|
WHERE bp.id > ${cutoff}
|
||||||
|
AND u.bannedBy IS NULL
|
||||||
|
GROUP BY u.username
|
||||||
|
`));
|
||||||
|
}));
|
||||||
|
|
||||||
|
router.get('/admin/ban-proposals/: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 fetchBanProposals(req.db, user.id));
|
||||||
|
}));
|
||||||
|
|
||||||
|
router.post('/admin/propose-ban/: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) {
|
if (!user) {
|
||||||
return res.status(400).json({error: 'No such user'});
|
return res.status(400).json({error: 'No such user'});
|
||||||
}
|
}
|
||||||
@ -208,20 +250,64 @@ router.post('/admin/ban/:username', handleErrorAsync(async (req, res) => {
|
|||||||
if (!req.body.terms.length) {
|
if (!req.body.terms.length) {
|
||||||
return res.status(400).json({error: 'Terms are required'});
|
return res.status(400).json({error: 'Terms are required'});
|
||||||
}
|
}
|
||||||
|
await req.db.get(SQL`
|
||||||
|
DELETE FROM ban_proposals
|
||||||
|
WHERE userId = ${user.id} AND bannedBy = ${req.user.id}
|
||||||
|
`);
|
||||||
|
await req.db.get(SQL`
|
||||||
|
INSERT INTO ban_proposals (id, userId, bannedBy, bannedTerms, bannedReason) VALUES (
|
||||||
|
${ulid()}, ${user.id},
|
||||||
|
${req.user.id}, ${req.body.terms.join(',')}, ${req.body.reason}
|
||||||
|
)`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await req.db.get(SQL`
|
||||||
|
DELETE FROM ban_proposals
|
||||||
|
WHERE userId = ${user.id} AND bannedBy = ${req.user.id}
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json(true);
|
||||||
|
}));
|
||||||
|
|
||||||
|
router.post('/admin/apply-ban/:username/:id', 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'});
|
||||||
|
}
|
||||||
|
|
||||||
|
const proposals = await fetchBanProposals(req.db, user.id);
|
||||||
|
|
||||||
|
if (req.params.id && req.params.id !== '0') {
|
||||||
|
if (!req.isGranted('*') && proposals.length < 3) {
|
||||||
|
return res.status(401).json({error: 'Unauthorised'});
|
||||||
|
}
|
||||||
|
const proposal = await req.db.get(SQL`SELECT * FROM ban_proposals WHERE id = ${req.params.id}`);
|
||||||
|
if (!proposal || proposal.userId !== user.id) {
|
||||||
|
return res.status(400).json({error: 'Invalid ban proposal id'});
|
||||||
|
}
|
||||||
await req.db.get(SQL`
|
await req.db.get(SQL`
|
||||||
UPDATE users
|
UPDATE users
|
||||||
SET bannedReason = ${req.body.reason},
|
SET bannedReason = ${proposal.bannedReason},
|
||||||
bannedTerms = ${req.body.terms.join(',')},
|
bannedTerms = ${proposal.bannedTerms},
|
||||||
bannedBy = ${req.user.id},
|
bannedBy = ${req.user.id},
|
||||||
banSnapshot = ${await profilesSnapshot(req.db, normalise(req.params.username))}
|
banSnapshot = ${await profilesSnapshot(req.db, normalise(req.params.username))}
|
||||||
WHERE id = ${user.id}
|
WHERE id = ${user.id}
|
||||||
`);
|
`);
|
||||||
await archiveBan(req.db, user);
|
await archiveBan(req.db, user);
|
||||||
mailer(user.email, 'ban', {reason: req.body.reason, username: normalise(req.params.username)});
|
mailer(user.email, 'ban', {reason: proposal.bannedReason, username: normalise(req.params.username)});
|
||||||
} else {
|
} else {
|
||||||
|
if (!req.isGranted('*')) {
|
||||||
|
return res.status(401).json({error: 'Unauthorised'});
|
||||||
|
}
|
||||||
await req.db.get(SQL`
|
await req.db.get(SQL`
|
||||||
UPDATE users
|
UPDATE users
|
||||||
SET bannedReason = null
|
SET bannedReason = null,
|
||||||
|
bannedBy = ${req.user.id}
|
||||||
WHERE id = ${user.id}
|
WHERE id = ${user.id}
|
||||||
`);
|
`);
|
||||||
await liftBan(req.db, user);
|
await liftBan(req.db, user);
|
||||||
|
@ -38,8 +38,8 @@ const replaceExtension = username => username
|
|||||||
;
|
;
|
||||||
|
|
||||||
export const saveAuthenticator = async (db, type, user, payload, validForMinutes = null) => {
|
export const saveAuthenticator = async (db, type, user, payload, validForMinutes = null) => {
|
||||||
if (await lookupBanArchive(db, type, payload)) {
|
if (!user && await lookupBanArchive(db, type, payload)) {
|
||||||
throw 'banned';
|
throw new Error('banned');
|
||||||
}
|
}
|
||||||
const id = ulid();
|
const id = ulid();
|
||||||
await db.get(SQL`INSERT INTO authenticators (id, userId, type, payload, validUntil) VALUES (
|
await db.get(SQL`INSERT INTO authenticators (id, userId, type, payload, validUntil) VALUES (
|
||||||
@ -316,8 +316,8 @@ router.post('/user/init', handleErrorAsync(async (req, res) => {
|
|||||||
return res.json({ error: 'user.account.changeEmail.invalid' })
|
return res.json({ error: 'user.account.changeEmail.invalid' })
|
||||||
}
|
}
|
||||||
|
|
||||||
if (await lookupBanArchive(req.db, 'email', payload)) {
|
if (!user && await lookupBanArchive(req.db, 'email', payload)) {
|
||||||
throw 'banned';
|
return res.status(401).json({error: 'Unauthorised'});
|
||||||
}
|
}
|
||||||
|
|
||||||
let codeKey;
|
let codeKey;
|
||||||
|
Loading…
x
Reference in New Issue
Block a user