#132 fine-grained permissions

This commit is contained in:
Avris 2020-12-31 00:03:30 +01:00
parent 2a31d688ba
commit 257db4099e
23 changed files with 182 additions and 85 deletions

View File

@ -26,7 +26,7 @@
</a>
</div>
</div>
<p v-if="$admin()">
<p v-if="$isGranted('panel') || $isGranted('users')">
<nuxt-link to="/admin" class="badge badge-primary"><T>user.account.admin</T></nuxt-link>
</p>
</div>

View File

@ -1,6 +1,6 @@
<template>
<Loading :value="nounsRaw">
<section v-if="$admin()" class="px-3">
<section v-if="$isGranted('nouns')" class="px-3">
<div class="alert alert-info">
<strong>{{ nounsCountApproved() }}</strong> <T>nouns.approved</T>,
<strong>{{ nounsCountPending() }}</strong> <T>nouns.pending</T>.
@ -142,7 +142,7 @@
</nuxt-link>
</li>
-->
<template v-if="$admin()">
<template v-if="$isGranted('nouns')">
<li v-if="!s.el.approved">
<button class="btn btn-concise btn-success btn-sm m-1" @click="approve(s.el)">
<Icon v="check"/>
@ -166,7 +166,7 @@
<button class="btn btn-concise btn-outline-primary btn-sm m-1" @click="edit(s.el)">
<Icon v="pen"/>
<span class="btn-label">
<T v-if="$admin()">crud.edit</T>
<T v-if="$isGranted('nouns')">crud.edit</T>
<T v-else>nouns.edit</T>
</span>
</button>

View File

@ -1,6 +1,6 @@
<template>
<Loading :value="entriesRaw">
<section v-if="$admin()" class="px-3">
<section v-if="$isGranted('inclusive')" class="px-3">
<div class="alert alert-info">
<strong>{{ entriesCountApproved() }}</strong> <T>nouns.approved</T>,
<strong>{{ entriesCountPending() }}</strong> <T>nouns.pending</T>.
@ -126,7 +126,7 @@
</nuxt-link>
</li>
-->
<template v-if="$admin()">
<template v-if="$isGranted('inclusive')">
<li v-if="!s.el.approved">
<button class="btn btn-concise btn-success btn-sm m-1" @click="approve(s.el)">
<Icon v="check"/>
@ -150,7 +150,7 @@
<button class="btn btn-concise btn-outline-primary btn-sm m-1" @click="edit(s.el)">
<Icon v="pen"/>
<span class="btn-label">
<T v-if="$admin()">crud.edit</T>
<T v-if="$isGranted('inclusive')">crud.edit</T>
<T v-else>nouns.edit</T>
</span>
</button>

62
components/Roles.vue Normal file
View File

@ -0,0 +1,62 @@
<template>
<form v-if="$isGranted('*')">
<ListInput v-model="roles" v-slot="s" :prototype="{locale: config.locale, area: '*'}">
<select v-model="s.val.locale" class="form-control">
<option v-for="l in allLocales" :value="l">{{l}}</option>
</select>
<select v-model="s.val.area" class="form-control">
<option v-for="a in allAreas" :value="a">{{a}}</option>
</select>
</ListInput>
<button class="btn btn-outline-primary" @click.prevent="save">Save</button>
</form>
<ul v-else class="list-unstyled">
<li v-for="role in user.roles.split('|')">
<span class="badge badge-primary">{{role}}</span>
</li>
</ul>
</template>
<script>
export default {
props: {
user: { required: true },
},
data() {
return {
roles: (this.user.roles ? this.user.roles.split('|') : []).map(r => {
if (r === '*') { r = '*-*'; }
const [ locale, area ] = r.split('-');
return { locale, area };
}),
allLocales: ['*', ...Object.keys(this.locales)],
allAreas: [
'*',
'panel',
'users',
'sources',
'nouns',
'terms',
'inclusive',
'census',
],
};
},
methods: {
async save() {
const roles = this.roles.map(r => {
if (r.locale === '*' && r.area === '*') {
return '*';
}
return `${r.locale}-${r.area}`;
}).join('|');
await this.$confirm(this.$t('admin.user.confirmRole', {username: this.user.username, role: roles}));
const response = await this.$axios.$post(`/user/${this.user.id}/set-roles`, { roles: roles});
this.user.roles = roles;
}
},
}
</script>

View File

@ -2,7 +2,7 @@
<div class="my-2" v-if="!deleted">
<Icon :v="source.icon()"/>
<strong><template v-if="source.author">{{source.author.replace('^', '')}}</template><span v-if="source.author"> </span><em><a v-if="source.link" :href="source.link" target="_blank" rel="noopener">{{source.title}}</a><span v-else>{{source.title}}</span></em></strong><template v-if="source.extra"> ({{source.extra}})</template>, {{source.year}}<template v-if="source.comment">; {{source.comment}}</template>
<ul class="list-inline" v-if="manage && $admin()">
<ul class="list-inline" v-if="manage && $isGranted('sources')">
<li v-if="!source.approved" class="list-inline-item">
<span class="badge badge-danger">
<Icon v="map-marker-question"/>

View File

@ -1,6 +1,6 @@
<template>
<Loading :value="entriesRaw">
<section v-if="$admin()" class="px-3">
<section v-if="$isGranted('terms')" class="px-3">
<div class="alert alert-info">
<strong>{{ entriesCountApproved() }}</strong> <T>nouns.approved</T>,
<strong>{{ entriesCountPending() }}</strong> <T>nouns.pending</T>.
@ -70,7 +70,7 @@
</nuxt-link>
</li>
-->
<template v-if="$admin()">
<template v-if="$$isGranted('terms')">
<li v-if="!s.el.approved">
<button class="btn btn-concise btn-success btn-sm m-1" @click="approve(s.el)">
<Icon v="check"/>
@ -94,7 +94,7 @@
<button class="btn btn-concise btn-outline-primary btn-sm m-1" @click="edit(s.el)">
<Icon v="pen"/>
<span class="btn-label">
<T v-if="$admin()">crud.edit</T>
<T v-if="$isGranted('terms')">crud.edit</T>
<T v-else>nouns.edit</T>
</span>
</button>

View File

@ -39,7 +39,7 @@
<textarea v-model="form.definition" class="form-control form-control-sm" required rows="3"></textarea>
</td>
</tr>
<tr v-if="$admin()">
<tr v-if="$isGranted('terms')">
<td colspan="3">
<T>profile.flags</T>
<ListInput v-model="form.flags" v-slot="s"/>

View File

@ -429,7 +429,7 @@ admin:
user:
user: 'User'
email: 'Email'
roles: 'Role'
roles: 'Permissions'
profiles: 'Profiles'
confirmRole: 'Are you sure you want to switch @%username%''s role to "%role%"?'

View File

@ -990,7 +990,7 @@ admin:
user:
user: 'Użytkownicze'
email: 'Email'
roles: 'Rola'
roles: 'Uprawnienia'
profiles: 'Profile'
confirmRole: 'Czy na pewno chcesz zmienić rolę osoby @%username% na "%role%"?'

View File

@ -0,0 +1,9 @@
-- Up
UPDATE users SET roles = '*' WHERE roles = 'admin';
UPDATE users SET roles = '' WHERE roles = 'user';
-- Down
UPDATE users SET roles = 'admin' WHERE roles = '*';
UPDATE users SET roles = 'user' WHERE roles = '';

View File

@ -1,5 +1,6 @@
import Vue from 'vue';
import t from "../src/translator";
import config from '../data/config.suml';
import {isGranted} from "../src/helpers";
export default ({app, store}) => {
const token = app.$cookies.get('token');
@ -11,7 +12,7 @@ export default ({app, store}) => {
}
Vue.prototype.$user = _ => store.state.user;
Vue.prototype.$admin = _ => {
return store.state.user && store.state.user.authenticated && store.state.user.roles === 'admin';
};
Vue.prototype.$isGranted = (area) => {
return store.state.user && store.state.user.authenticated && isGranted(store.state.user, config.locale, area);
}
}

View File

@ -1,20 +1,34 @@
<template>
<NotFound v-if="!$admin()"/>
<NotFound v-if="!$isGranted('panel') && !$isGranted('users')"/>
<div v-else>
<h2>
<Icon v="user-cog"/>
<T>admin.header</T>
</h2>
<section>
<section v-if="$isGranted('users')">
<details class="border mb-3">
<summary class="bg-light p-3">
<Icon v="users"/>
Users
({{stats.users.overall}}, {{stats.users.admins}} admins)
({{stats.users.overall}} overall, {{stats.users.admins}} admins, {{visibleUsers.length}} visible)
</summary>
<div class="border-top">
<input class="form-control mt-4" v-model="userFilter" :placeholder="$t('crud.filterLong')"/>
<div class="input-group mt-4">
<input class="form-control" v-model="userFilter" :placeholder="$t('crud.filterLong')"/>
<span class="input-group-append">
<button :class="['btn', adminsFilter ? 'btn-secondary' : 'btn-outline-secondary']"
@click="adminsFilter = !adminsFilter"
>
Only admins
</button>
<button :class="['btn', localeFilter ? 'btn-secondary' : 'btn-outline-secondary']"
@click="localeFilter = !localeFilter"
>
Only this version
</button>
</span>
</div>
<Table :data="visibleUsers" :columns="4">
<template v-slot:header>
<th class="text-nowrap">
@ -49,10 +63,7 @@
</ul>
</td>
<td>
<a href="#" :class="['badge', s.el.roles === 'admin' ? 'badge-primary' : 'badge-light']"
@click.prevent="setRole(s.el.id, s.el.roles === 'admin' ? 'user' : 'admin')">
{{s.el.roles}}
</a>
<Roles :user="s.el"/>
</td>
<td>
<ul class="list-unstyled">
@ -117,7 +128,7 @@
</template>
<script>
import {head} from "../src/helpers";
import {head, isGranted} from "../src/helpers";
import {socialProviders} from "../src/data";
export default {
@ -125,34 +136,34 @@
return {
socialProviders,
userFilter: '',
localeFilter: true,
adminsFilter: false,
}
},
async asyncData({ app, store }) {
if (!store.state.user || store.state.user.roles !== 'admin') {
return {};
}
let stats = { users: {}};
let users = {};
const stats = await app.$axios.$get(`/admin/stats`);
try {
stats = await app.$axios.$get(`/admin/stats`);
} catch {}
const users = await app.$axios.$get(`/admin/users`);
try {
users = await app.$axios.$get(`/admin/users`);
} catch {}
return {
stats,
users,
};
},
methods: {
async setRole(userId, role) {
await this.$confirm(this.$t('admin.user.confirmRole', {username: this.users[userId].username, role}));
const response = await this.$axios.$post(`/user/${userId}/set-roles`, { roles: role });
this.users[userId].roles = role;
}
},
computed: {
visibleUsers() {
return Object.values(this.users).filter(u => u.username.toLowerCase().includes(this.userFilter.toLowerCase()));
return Object.values(this.users).filter(u =>
u.username.toLowerCase().includes(this.userFilter.toLowerCase())
&& (!this.adminsFilter || u.roles !== '')
&& (!this.localeFilter || u.profiles.includes(this.config.locale))
);
},
},
head() {

View File

@ -6,7 +6,7 @@
</h2>
<template v-if="q === null">
<section v-if="$admin()">
<section v-if="$isGranted('census')">
<div class="alert alert-info">
{{countResponses}}
<T>census.replies</T>

View File

@ -66,7 +66,7 @@
</ul>
</section>
<section v-if="$admin()" class="px-3">
<section v-if="$isGranted('sources')" class="px-3">
<div class="alert alert-info">
<strong>{{ sourceLibrary.countApproved }}</strong> <T>nouns.approved</T>,
<a href="#" @click.prevent="filter='__awaiting__'">

View File

@ -6,7 +6,7 @@ import cookieParser from 'cookie-parser';
import grant from "grant";
import router from "./routes/user";
import { loadSuml } from './loader';
import {buildLocaleList} from "../src/helpers";
import {buildLocaleList, isGranted} from "../src/helpers";
global.config = loadSuml('config');
@ -27,7 +27,7 @@ app.use(async function (req, res, next) {
req.locales = buildLocaleList(global.config.locale);
req.rawUser = authenticate(req);
req.user = req.rawUser && req.rawUser.authenticated ? req.rawUser : null;
req.admin = req.user && req.user.roles === 'admin';
req.isGranted = (area, locale = global.config.locale) => req.user && isGranted(req.user, locale, area);
req.db = await dbConnection();
next();
});

View File

@ -7,7 +7,7 @@ import {now, sortByValue} from "../../src/helpers";
const router = Router();
router.get('/admin/users', async (req, res) => {
if (!req.admin) {
if (!req.isGranted('users')) {
return res.status(401).json({error: 'Unauthorised'});
}
@ -15,7 +15,7 @@ router.get('/admin/users', async (req, res) => {
SELECT u.id, u.username, u.email, u.roles, u.avatarSource, p.locale
FROM users u
LEFT JOIN profiles p ON p.userId = u.id
ORDER BY u.roles ASC, u.id DESC
ORDER BY u.id DESC
`);
const authenticators = await req.db.all(SQL`
@ -47,18 +47,19 @@ router.get('/admin/users', async (req, res) => {
});
router.get('/admin/stats', async (req, res) => {
if (!req.admin) {
if (!req.isGranted('panel')) {
return res.status(401).json({error: 'Unauthorised'});
}
const users = {
overall: (await req.db.get(SQL`SELECT count(*) AS c FROM users`)).c,
admins: (await req.db.get(SQL`SELECT count(*) AS c FROM users WHERE roles=${'admin'}`)).c,
admins: (await req.db.get(SQL`SELECT count(*) AS c FROM users WHERE roles!=''`)).c,
};
const locales = {};
for (let locale in req.locales) {
if (!req.locales.hasOwnProperty(locale)) { continue; }
if (!req.isGranted('panel', locale)) { continue; }
const profiles = await req.db.all(SQL`SELECT pronouns, flags FROM profiles WHERE locale=${locale}`);
const pronouns = {}
const flags = {}

View File

@ -26,7 +26,7 @@ router.get('/inclusive', async (req, res) => {
SELECT i.*, u.username AS author FROM inclusive i
LEFT JOIN users u ON i.author_id = u.id
WHERE i.locale = ${req.config.locale}
AND i.approved >= ${req.admin ? 0 : 1}
AND i.approved >= ${req.isGranted('inclusive') ? 0 : 1}
AND i.deleted = 0
ORDER BY i.approved, i.insteadOf
`));
@ -38,7 +38,7 @@ router.get('/inclusive/search/:term', async (req, res) => {
SELECT i.*, u.username AS author FROM inclusive i
LEFT JOIN users u ON i.author_id = u.id
WHERE i.locale = ${req.config.locale}
AND i.approved >= ${req.admin ? 0 : 1}
AND i.approved >= ${req.isGranted('inclusive') ? 0 : 1}
AND i.deleted = 0
AND (i.insteadOf like ${term} OR i.say like ${term})
ORDER BY i.approved, i.insteadOf
@ -61,7 +61,7 @@ router.post('/inclusive/submit', async (req, res) => {
)
`);
if (req.admin) {
if (req.isGranted('inclusive')) {
await approve(req.db, id);
}
@ -69,7 +69,7 @@ router.post('/inclusive/submit', async (req, res) => {
});
router.post('/inclusive/hide/:id', async (req, res) => {
if (!req.admin) {
if (!req.isGranted('inclusive')) {
res.status(401).json({error: 'Unauthorised'});
}
@ -83,7 +83,7 @@ router.post('/inclusive/hide/:id', async (req, res) => {
});
router.post('/inclusive/approve/:id', async (req, res) => {
if (!req.admin) {
if (!req.isGranted('inclusive')) {
res.status(401).json({error: 'Unauthorised'});
}
@ -93,7 +93,7 @@ router.post('/inclusive/approve/:id', async (req, res) => {
});
router.post('/inclusive/remove/:id', async (req, res) => {
if (!req.admin) {
if (!req.isGranted('inclusive')) {
res.status(401).json({error: 'Unauthorised'});
}

View File

@ -31,7 +31,7 @@ router.get('/nouns', async (req, res) => {
LEFT JOIN users u ON n.author_id = u.id
WHERE n.locale = ${req.config.locale}
AND n.deleted = 0
AND n.approved >= ${req.admin ? 0 : 1}
AND n.approved >= ${req.isGranted('nouns') ? 0 : 1}
ORDER BY n.approved, n.masc
`));
});
@ -42,7 +42,7 @@ router.get('/nouns/search/:term', async (req, res) => {
SELECT n.*, u.username AS author FROM nouns n
LEFT JOIN users u ON n.author_id = u.id
WHERE n.locale = ${req.config.locale}
AND n.approved >= ${req.admin ? 0 : 1}
AND n.approved >= ${req.isGranted('nouns') ? 0 : 1}
AND n.deleted = 0
AND (n.masc like ${term} OR n.fem like ${term} OR n.neutr like ${term} OR n.mascPl like ${term} OR n.femPl like ${term} OR n.neutrPl like ${term})
ORDER BY n.approved, n.masc
@ -65,7 +65,7 @@ router.post('/nouns/submit', async (req, res) => {
)
`);
if (req.admin) {
if (req.isGranted('nouns')) {
await approve(req.db, id);
}
@ -73,7 +73,7 @@ router.post('/nouns/submit', async (req, res) => {
});
router.post('/nouns/hide/:id', async (req, res) => {
if (!req.admin) {
if (!req.isGranted('nouns')) {
res.status(401).json({error: 'Unauthorised'});
}
@ -87,7 +87,7 @@ router.post('/nouns/hide/:id', async (req, res) => {
});
router.post('/nouns/approve/:id', async (req, res) => {
if (!req.admin) {
if (!req.isGranted('nouns')) {
res.status(401).json({error: 'Unauthorised'});
}
@ -97,7 +97,7 @@ router.post('/nouns/approve/:id', async (req, res) => {
});
router.post('/nouns/remove/:id', async (req, res) => {
if (!req.admin) {
if (!req.isGranted('nouns')) {
res.status(401).json({error: 'Unauthorised'});
}
@ -128,7 +128,7 @@ router.get('/nouns/:word.png', async (req, res) => {
const noun = (await req.db.all(SQL`
SELECT * FROM nouns
WHERE locale = ${req.config.locale}
AND approved >= ${req.admin ? 0 : 1}
AND approved >= ${req.isGranted('nouns') ? 0 : 1}
AND (masc like ${term} OR fem like ${term} OR neutr like ${term} OR mascPl like ${term} OR femPl like ${term} OR neutrPl like ${term})
ORDER BY masc
`)).filter(noun =>

View File

@ -26,7 +26,7 @@ router.get('/sources', async (req, res) => {
LEFT JOIN users u ON s.submitter_id = u.id
WHERE s.locale = ${req.config.locale}
AND s.deleted = 0
AND s.approved >= ${req.admin ? 0 : 1}
AND s.approved >= ${req.isGranted('sources') ? 0 : 1}
`));
});
@ -36,7 +36,7 @@ router.get('/sources/:id', async (req, res) => {
LEFT JOIN users u ON s.submitter_id = u.id
WHERE s.locale = ${req.config.locale}
AND s.deleted = 0
AND s.approved >= ${req.admin ? 0 : 1}
AND s.approved >= ${req.isGranted('sources') ? 0 : 1}
AND s.id = ${req.params.id}
`));
});
@ -53,7 +53,7 @@ router.post('/sources/submit', async (req, res) => {
)
`);
if (req.admin) {
if (req.isGranted('sources')) {
await approve(req.db, id);
}
@ -61,7 +61,7 @@ router.post('/sources/submit', async (req, res) => {
});
router.post('/sources/hide/:id', async (req, res) => {
if (!req.admin) {
if (!req.isGranted('sources')) {
res.status(401).json({error: 'Unauthorised'});
}
@ -75,7 +75,7 @@ router.post('/sources/hide/:id', async (req, res) => {
});
router.post('/sources/approve/:id', async (req, res) => {
if (!req.admin) {
if (!req.isGranted('sources')) {
res.status(401).json({error: 'Unauthorised'});
}
@ -85,7 +85,7 @@ router.post('/sources/approve/:id', async (req, res) => {
});
router.post('/sources/remove/:id', async (req, res) => {
if (!req.admin) {
if (!req.isGranted('sources')) {
res.status(401).json({error: 'Unauthorised'});
}

View File

@ -26,7 +26,7 @@ router.get('/terms', async (req, res) => {
SELECT i.*, u.username AS author FROM terms i
LEFT JOIN users u ON i.author_id = u.id
WHERE i.locale = ${req.config.locale}
AND i.approved >= ${req.admin ? 0 : 1}
AND i.approved >= ${req.isGranted('terms') ? 0 : 1}
AND i.deleted = 0
ORDER BY i.term
`));
@ -38,7 +38,7 @@ router.get('/terms/search/:term', async (req, res) => {
SELECT i.*, u.username AS author FROM terms i
LEFT JOIN users u ON i.author_id = u.id
WHERE i.locale = ${req.config.locale}
AND i.approved >= ${req.admin ? 0 : 1}
AND i.approved >= ${req.isGranted('terms') ? 0 : 1}
AND i.deleted = 0
AND (i.term like ${term} OR i.original like ${term})
ORDER BY i.term
@ -61,7 +61,7 @@ router.post('/terms/submit', async (req, res) => {
)
`);
if (req.admin) {
if (req.isGranted('terms')) {
await approve(req.db, id);
}
@ -69,7 +69,7 @@ router.post('/terms/submit', async (req, res) => {
});
router.post('/terms/hide/:id', async (req, res) => {
if (!req.admin) {
if (!req.isGranted('terms')) {
res.status(401).json({error: 'Unauthorised'});
}
@ -83,7 +83,7 @@ router.post('/terms/hide/:id', async (req, res) => {
});
router.post('/terms/approve/:id', async (req, res) => {
if (!req.admin) {
if (!req.isGranted('terms')) {
res.status(401).json({error: 'Unauthorised'});
}
@ -93,7 +93,7 @@ router.post('/terms/approve/:id', async (req, res) => {
});
router.post('/terms/remove/:id', async (req, res) => {
if (!req.admin) {
if (!req.isGranted('terms')) {
res.status(401).json({error: 'Unauthorised'});
}

View File

@ -276,7 +276,7 @@ router.post('/user/delete', async (req, res) => {
});
router.post('/user/:id/set-roles', async (req, res) => {
if (!req.admin) {
if (!req.isGranted('*')) {
return res.status(401).json({error: 'Unauthorised'});
}

View File

@ -175,3 +175,21 @@ export const shuffle = a => {
}
return a;
}
export const isGranted = (user, locale, area) => {
if (area === '*') {
return user.roles.split('|').includes('*');
}
for (let permission of user.roles.split('|')) {
if (permission === '*') {
return true;
}
const [ permissionLocale, permissionArea ] = permission.split('-');
if ((permissionLocale === '*' || permissionLocale === locale) && (permissionArea === '*' || permissionArea === area)) {
return true;
}
}
return false;
}

View File

@ -2467,15 +2467,10 @@ caniuse-api@^3.0.0:
lodash.memoize "^4.1.2"
lodash.uniq "^4.5.0"
caniuse-lite@^1.0.0, caniuse-lite@^1.0.30000981, caniuse-lite@^1.0.30001093, caniuse-lite@^1.0.30001097:
version "1.0.30001102"
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001102.tgz#3275e7a8d09548f955f665e532df88de0b63741a"
integrity sha512-fOjqRmHjRXv1H1YD6QVLb96iKqnu17TjcLSaX64TwhGYed0P1E1CCWZ9OujbbK4Z/7zax7zAzvQidzdtjx8RcA==
caniuse-lite@^1.0.30001148:
version "1.0.30001151"
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001151.tgz#1ddfde5e6fff02aad7940b4edb7d3ac76b0cb00b"
integrity sha512-Zh3sHqskX6mHNrqUerh+fkf0N72cMxrmflzje/JyVImfpknscMnkeJrlFGJcqTmaa0iszdYptGpWMJCRQDkBVw==
caniuse-lite@^1.0.0, caniuse-lite@^1.0.30000981, caniuse-lite@^1.0.30001093, caniuse-lite@^1.0.30001097, caniuse-lite@^1.0.30001148:
version "1.0.30001171"
resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001171.tgz"
integrity sha512-5Alrh8TTYPG9IH4UkRqEBZoEToWRLvPbSQokvzSz0lii8/FOWKG4keO1HoYfPWs8IF/NH/dyNPg1cmJGvV3Zlg==
canvas@^2.6.1:
version "2.6.1"