mirror of
https://gitlab.com/PronounsPage/PronounsPage.git
synced 2025-09-19 04:27:05 -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>
|
||||
</a>
|
||||
<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>
|
||||
<div class="form-group">
|
||||
<p class="my-1"><label><strong><T>ban.terms</T><T>quotation.colon</T></strong></label></p>
|
||||
@ -69,24 +105,30 @@
|
||||
reported: false,
|
||||
|
||||
showBanForm: !!this.user.bannedReason,
|
||||
isBanned: !!this.user.bannedReason,
|
||||
|
||||
saving: false,
|
||||
|
||||
forbidden,
|
||||
|
||||
abuseReports: [],
|
||||
banProposals: [],
|
||||
}
|
||||
},
|
||||
async mounted() {
|
||||
if (!this.$isGranted('users')) { return; }
|
||||
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: {
|
||||
async ban() {
|
||||
await this.$confirm(this.$t('ban.confirm', {username: this.user.username}), 'danger');
|
||||
this.saving = true;
|
||||
try {
|
||||
await this.$post(`/admin/ban/${encodeURIComponent(this.user.username)}`, {
|
||||
await this.$post(`/admin/propose-ban/${encodeURIComponent(this.user.username)}`, {
|
||||
reason: this.user.bannedReason,
|
||||
terms: this.user.bannedTerms,
|
||||
});
|
||||
@ -95,6 +137,16 @@
|
||||
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() {
|
||||
if (!this.reportComment) { return; }
|
||||
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>
|
||||
|
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>
|
||||
</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')">
|
||||
<h3>
|
||||
<Icon v="siren-on"/>
|
||||
@ -306,6 +341,7 @@ import {deepSet, head} from "../src/helpers";
|
||||
missingTranslations: translator.listMissingTranslations(),
|
||||
abuseReports: undefined,
|
||||
translationProposals: undefined,
|
||||
banProposals: undefined,
|
||||
}
|
||||
},
|
||||
async asyncData({ app, store }) {
|
||||
@ -326,6 +362,10 @@ import {deepSet, head} from "../src/helpers";
|
||||
this.$axios.$get(`/translations/proposals`)
|
||||
.then(r => this.translationProposals = r)
|
||||
.catch();
|
||||
|
||||
this.$axios.$get(`/admin/ban-proposals`)
|
||||
.then(r => this.banProposals = r)
|
||||
.catch();
|
||||
},
|
||||
methods: {
|
||||
async impersonate(email) {
|
||||
|
@ -12,7 +12,7 @@ import buildLocaleList from "../../src/buildLocaleList";
|
||||
import {archiveBan, liftBan} from "../ban";
|
||||
import marked from 'marked';
|
||||
import {loadCurrentUser} from "./user";
|
||||
import {encodeTime} from "ulid";
|
||||
import {encodeTime, ulid} from "ulid";
|
||||
|
||||
const router = Router();
|
||||
|
||||
@ -194,12 +194,54 @@ router.get('/admin/stats-public', handleErrorAsync(async (req, res) => {
|
||||
|
||||
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')) {
|
||||
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) {
|
||||
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) {
|
||||
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`
|
||||
UPDATE users
|
||||
SET bannedReason = ${req.body.reason},
|
||||
bannedTerms = ${req.body.terms.join(',')},
|
||||
SET bannedReason = ${proposal.bannedReason},
|
||||
bannedTerms = ${proposal.bannedTerms},
|
||||
bannedBy = ${req.user.id},
|
||||
banSnapshot = ${await profilesSnapshot(req.db, normalise(req.params.username))}
|
||||
WHERE id = ${user.id}
|
||||
`);
|
||||
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 {
|
||||
if (!req.isGranted('*')) {
|
||||
return res.status(401).json({error: 'Unauthorised'});
|
||||
}
|
||||
await req.db.get(SQL`
|
||||
UPDATE users
|
||||
SET bannedReason = null
|
||||
SET bannedReason = null,
|
||||
bannedBy = ${req.user.id}
|
||||
WHERE id = ${user.id}
|
||||
`);
|
||||
await liftBan(req.db, user);
|
||||
|
@ -38,8 +38,8 @@ const replaceExtension = username => username
|
||||
;
|
||||
|
||||
export const saveAuthenticator = async (db, type, user, payload, validForMinutes = null) => {
|
||||
if (await lookupBanArchive(db, type, payload)) {
|
||||
throw 'banned';
|
||||
if (!user && await lookupBanArchive(db, type, payload)) {
|
||||
throw new Error('banned');
|
||||
}
|
||||
const id = ulid();
|
||||
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' })
|
||||
}
|
||||
|
||||
if (await lookupBanArchive(req.db, 'email', payload)) {
|
||||
throw 'banned';
|
||||
if (!user && await lookupBanArchive(req.db, 'email', payload)) {
|
||||
return res.status(401).json({error: 'Unauthorised'});
|
||||
}
|
||||
|
||||
let codeKey;
|
||||
|
Loading…
x
Reference in New Issue
Block a user