0
0
Fork 0

Compare commits

...

2 Commits

Author SHA1 Message Date
Alex Moreno 9d3569ff6e feat: add useAcl checkUrl 2024-09-20 11:43:40 +02:00
Alex Moreno bb85e8b3cb feat(useAcl): create checkUrl 2024-09-20 11:43:15 +02:00
9 changed files with 155 additions and 32 deletions

View File

@ -3,6 +3,8 @@ import qFormMixin from './qformMixin';
import mainShortcutMixin from './mainShortcutMixin'; import mainShortcutMixin from './mainShortcutMixin';
import keyShortcut from './keyShortcut'; import keyShortcut from './keyShortcut';
import useNotify from 'src/composables/useNotify.js'; import useNotify from 'src/composables/useNotify.js';
import { AccessError } from 'src/utils/errors';
const { notify } = useNotify(); const { notify } = useNotify();
export default boot(({ app }) => { export default boot(({ app }) => {
@ -11,6 +13,12 @@ export default boot(({ app }) => {
app.directive('shortcut', keyShortcut); app.directive('shortcut', keyShortcut);
app.config.errorHandler = function (err) { app.config.errorHandler = function (err) {
console.error(err); console.error(err);
switch (err.constructor) {
case AccessError:
notify('errors.statusUnauthorized', 'negative', 'account_circle_off');
break;
default:
notify('globals.error', 'negative', 'error'); notify('globals.error', 'negative', 'error');
}
}; };
}); });

View File

@ -1,6 +1,6 @@
<script setup> <script setup>
import axios from 'axios'; import axios from 'axios';
import { computed, ref, watch } from 'vue'; import { computed, ref, watch, onMounted } from 'vue';
import { useRouter, onBeforeRouteLeave } from 'vue-router'; import { useRouter, onBeforeRouteLeave } from 'vue-router';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useQuasar } from 'quasar'; import { useQuasar } from 'quasar';
@ -10,12 +10,14 @@ import VnPaginate from 'components/ui/VnPaginate.vue';
import VnConfirm from 'components/ui/VnConfirm.vue'; import VnConfirm from 'components/ui/VnConfirm.vue';
import SkeletonTable from 'components/ui/SkeletonTable.vue'; import SkeletonTable from 'components/ui/SkeletonTable.vue';
import { tMobile } from 'src/composables/tMobile'; import { tMobile } from 'src/composables/tMobile';
import { useAcl } from 'src/composables/useAcl';
const { push } = useRouter(); const { push } = useRouter();
const quasar = useQuasar(); const quasar = useQuasar();
const stateStore = useStateStore(); const stateStore = useStateStore();
const { t } = useI18n(); const { t } = useI18n();
const { validate } = useValidator(); const { validate } = useValidator();
const { checkUrl } = useAcl();
const $props = defineProps({ const $props = defineProps({
model: { model: {
@ -71,6 +73,10 @@ const $props = defineProps({
type: Boolean, type: Boolean,
default: true, default: true,
}, },
checkPermissions: {
type: Boolean,
default: true,
},
}); });
const isLoading = ref(false); const isLoading = ref(false);
@ -80,6 +86,9 @@ const vnPaginateRef = ref();
const formData = ref(); const formData = ref();
const saveButtonRef = ref(null); const saveButtonRef = ref(null);
const watchChanges = ref(); const watchChanges = ref();
const saveChangeUrl = ref();
const hasWritePremission = ref();
const accessDeniedTitle = ref(t('errors.statusUnauthorized'));
const formUrl = computed(() => $props.url); const formUrl = computed(() => $props.url);
const emit = defineEmits(['onFetch', 'update:selected', 'saveChanges']); const emit = defineEmits(['onFetch', 'update:selected', 'saveChanges']);
@ -95,6 +104,8 @@ defineExpose({
getChanges, getChanges,
formData, formData,
vnPaginateRef, vnPaginateRef,
hasWritePremission,
accessDeniedTitle,
}); });
onBeforeRouteLeave((to, from, next) => { onBeforeRouteLeave((to, from, next) => {
@ -110,6 +121,17 @@ onBeforeRouteLeave((to, from, next) => {
else next(); else next();
}); });
onMounted(() => {
saveChangeUrl.value = $props.saveUrl || $props.url + '/crud';
hasWritePremission.value =
!$props.checkPermissions || checkUrl(saveChangeUrl.value, 'post');
});
watch(formUrl, async () => {
originalData.value = null;
reset();
});
async function fetch(data) { async function fetch(data) {
resetData(data); resetData(data);
emit('onFetch', data); emit('onFetch', data);
@ -173,7 +195,7 @@ async function saveChanges(data) {
} }
const changes = data || getChanges(); const changes = data || getChanges();
try { try {
await axios.post($props.saveUrl || $props.url + '/crud', changes); await axios.post(saveChangeUrl.value, changes);
} catch (e) { } catch (e) {
return (isLoading.value = false); return (isLoading.value = false);
} }
@ -299,11 +321,6 @@ async function reload(params) {
const data = await vnPaginateRef.value.fetch(params); const data = await vnPaginateRef.value.fetch(params);
fetch(data); fetch(data);
} }
watch(formUrl, async () => {
originalData.value = null;
reset();
});
</script> </script>
<template> <template>
<VnPaginate <VnPaginate
@ -337,8 +354,8 @@ watch(formUrl, async () => {
icon="delete" icon="delete"
flat flat
@click="remove(selected)" @click="remove(selected)"
:disable="!selected?.length" :disable="!selected?.length || !hasWritePremission"
:title="t('globals.remove')" :title="hasWritePremission ? t('globals.remove') : accessDeniedTitle"
v-if="$props.defaultRemove" v-if="$props.defaultRemove"
/> />
<QBtn <QBtn
@ -355,8 +372,10 @@ watch(formUrl, async () => {
v-if="$props.goTo && $props.defaultSave" v-if="$props.goTo && $props.defaultSave"
@click="onSubmitAndGo" @click="onSubmitAndGo"
:label="tMobile('globals.saveAndContinue')" :label="tMobile('globals.saveAndContinue')"
:title="t('globals.saveAndContinue')" :title="
:disable="!hasChanges" hasWritePremission ? t('globals.saveAndContinue') : accessDeniedTitle
"
:disable="!hasChanges || !hasWritePremission"
color="primary" color="primary"
icon="save" icon="save"
split split
@ -390,8 +409,8 @@ watch(formUrl, async () => {
color="primary" color="primary"
icon="save" icon="save"
@click="onSubmit" @click="onSubmit"
:disable="!hasChanges" :disable="!hasChanges || !hasWritePremission"
:title="t('globals.save')" :title="hasWritePremission ? t('globals.save') : accessDeniedTitle"
/> />
<slot name="moreAfterActions" /> <slot name="moreAfterActions" />
</QBtnGroup> </QBtnGroup>

View File

@ -13,6 +13,7 @@ import VnConfirm from './ui/VnConfirm.vue';
import { tMobile } from 'src/composables/tMobile'; import { tMobile } from 'src/composables/tMobile';
import { useArrayData } from 'src/composables/useArrayData'; import { useArrayData } from 'src/composables/useArrayData';
import { useRoute } from 'vue-router'; import { useRoute } from 'vue-router';
import { useAcl } from 'src/composables/useAcl';
const { push } = useRouter(); const { push } = useRouter();
const quasar = useQuasar(); const quasar = useQuasar();
@ -21,6 +22,8 @@ const stateStore = useStateStore();
const { t } = useI18n(); const { t } = useI18n();
const { validate } = useValidator(); const { validate } = useValidator();
const { notify } = useNotify(); const { notify } = useNotify();
const { checkUrl } = useAcl();
const route = useRoute(); const route = useRoute();
const myForm = ref(null); const myForm = ref(null);
const $props = defineProps({ const $props = defineProps({
@ -91,6 +94,10 @@ const $props = defineProps({
type: Boolean, type: Boolean,
default: true, default: true,
}, },
checkPermissions: {
type: Boolean,
default: true,
},
}); });
const emit = defineEmits(['onFetch', 'onDataSaved']); const emit = defineEmits(['onFetch', 'onDataSaved']);
const modelValue = computed( const modelValue = computed(
@ -104,6 +111,15 @@ const isResetting = ref(false);
const hasChanges = ref(!$props.observeFormChanges); const hasChanges = ref(!$props.observeFormChanges);
const originalData = ref({}); const originalData = ref({});
const formData = computed(() => state.get(modelValue)); const formData = computed(() => state.get(modelValue));
const saveUrl = computed(
() => $props.urlCreate || $props.urlUpdate || $props.url || arrayData.store.url
);
const saveMethod = computed(() => ($props.urlCreate ? 'post' : 'patch'));
const hasWritePremission = computed(
() =>
$props.saveFn ||
(!$props.checkPermissions && checkUrl(saveUrl.value, saveMethod.value))
);
const defaultButtons = computed(() => ({ const defaultButtons = computed(() => ({
save: { save: {
color: 'primary', color: 'primary',
@ -184,6 +200,7 @@ onUnmounted(() => {
async function fetch() { async function fetch() {
try { try {
checkUrl($props.url, 'get', true);
let { data } = await axios.get($props.url, { let { data } = await axios.get($props.url, {
params: { filter: JSON.stringify($props.filter) }, params: { filter: JSON.stringify($props.filter) },
}); });
@ -204,13 +221,10 @@ async function save() {
try { try {
formData.value = trimData(formData.value); formData.value = trimData(formData.value);
const body = $props.mapper ? $props.mapper(formData.value) : formData.value; const body = $props.mapper ? $props.mapper(formData.value) : formData.value;
const method = $props.urlCreate ? 'post' : 'patch';
const url =
$props.urlCreate || $props.urlUpdate || $props.url || arrayData.store.url;
let response; let response;
if ($props.saveFn) response = await $props.saveFn(body); if ($props.saveFn) response = await $props.saveFn(body);
else response = await axios[method](url, body); else response = await axios[saveMethod.value](saveUrl.value, body);
if ($props.urlCreate) notify('globals.dataCreated', 'positive'); if ($props.urlCreate) notify('globals.dataCreated', 'positive');
@ -323,7 +337,7 @@ defineExpose({
@click="saveAndGo" @click="saveAndGo"
:label="tMobile('globals.saveAndContinue')" :label="tMobile('globals.saveAndContinue')"
:title="t('globals.saveAndContinue')" :title="t('globals.saveAndContinue')"
:disable="!hasChanges" :disable="!hasChanges || !hasWritePremission"
color="primary" color="primary"
icon="save" icon="save"
split split
@ -355,7 +369,7 @@ defineExpose({
color="primary" color="primary"
icon="save" icon="save"
@click="defaultButtons.save.click" @click="defaultButtons.save.click"
:disable="!hasChanges" :disable="!hasChanges || !hasWritePremission"
:title="t(defaultButtons.save.label)" :title="t(defaultButtons.save.label)"
/> />
</QBtnGroup> </QBtnGroup>

View File

@ -1,5 +1,6 @@
import axios from 'axios'; import axios from 'axios';
import { useState } from './useState'; import { useState } from './useState';
import { AccessError } from 'src/utils/errors';
export function useAcl() { export function useAcl() {
const state = useState(); const state = useState();
@ -8,31 +9,86 @@ export function useAcl() {
const { data } = await axios.get('VnUsers/acls'); const { data } = await axios.get('VnUsers/acls');
const acls = {}; const acls = {};
data.forEach((acl) => { data.forEach((acl) => {
acls[acl.model] = acls[acl.model] || {}; const model = acl.model.toLowerCase();
acls[acl.model][acl.property] = acls[acl.model][acl.property] || {}; const property = acl.property.toLowerCase();
acls[acl.model][acl.property][acl.accessType] = true; acls[model] = acls[model] || {};
acls[model][property] = acls[model][property] || {};
acls[model][property][acl.accessType] = true;
}); });
state.setAcls(acls); state.setAcls(acls);
} }
function hasAny(acls) { function hasAny(acls) {
let result = false;
for (const acl of acls) { for (const acl of acls) {
let { model, props, accessType } = acl; let { model, props, accessType } = acl;
const modelAcls = state.getAcls().value[model]; const modelAcls = state.getAcls().value[model.toLowerCase()];
Array.isArray(props) || (props = [props]); Array.isArray(props) || (props = [props]);
if (modelAcls) if (modelAcls) {
return ['*', ...props].some((key) => { result = ['*', ...props].some((key) => {
const acl = modelAcls[key]; const acl = modelAcls[key.toLowerCase()];
return acl && (acl['*'] || acl[accessType]); return acl && (acl['*'] || acl[accessType]);
}); });
} }
return false; if (result) return result;
}
return result;
}
function checkRead(model, urlSplit) {
const acls = [];
let props = urlSplit[1] ?? 'find';
if (typeof +props == 'number') {
props = urlSplit[2] ?? 'findById';
acls.push({ model, props: `__get__${props}`, accessType: 'READ' });
}
acls.push({ model, props, accessType: 'READ' });
console.log('acls: ', acls);
console.log('hasAny(acls): ', hasAny(acls));
return hasAny(acls);
}
function checkWrite(model, urlSplit, type) {
const acls = [];
let props = urlSplit[1] ?? (type != 'post' ? 'upsert' : 'create');
if (typeof +props == 'number') {
if (!urlSplit[2]) props = 'updateAttributes';
else if (['post', 'delete'].includes(type)) {
let prefix = 'create';
if (type == 'delete') prefix = type;
acls.push({ model, props: `__${prefix}__${props}`, accessType: 'WRITE' });
}
}
acls.push({ model, props, accessType: 'WRITE' });
console.log('acls: ', acls);
console.log('hasAny(acls): ', hasAny(acls));
return hasAny(acls);
}
function checkUrl(url, type = 'get', throwError, model) {
if (!url) return true;
const urlSplit = url.split('/');
model ??= urlSplit[0]?.slice(1, -1);
type = type.toLowerCase();
let hasPermission;
if (type == 'get') hasPermission = checkRead(model, urlSplit);
else hasPermission = checkWrite(model, urlSplit, type);
if (throwError && !hasPermission) throw new AccessError(url);
return hasPermission;
} }
return { return {
fetch, fetch,
hasAny, hasAny,
state, state,
checkRead,
checkWrite,
checkUrl,
}; };
} }

View File

@ -3,6 +3,7 @@ import { useRouter, useRoute } from 'vue-router';
import axios from 'axios'; import axios from 'axios';
import { useArrayDataStore } from 'stores/useArrayDataStore'; import { useArrayDataStore } from 'stores/useArrayDataStore';
import { buildFilter } from 'filters/filterPanel'; import { buildFilter } from 'filters/filterPanel';
import { useAcl } from 'src/composables/useAcl';
const arrayDataStore = useArrayDataStore(); const arrayDataStore = useArrayDataStore();
@ -14,6 +15,8 @@ export function useArrayData(key = useRoute().meta.moduleName, userOptions) {
const store = arrayDataStore.get(key); const store = arrayDataStore.get(key);
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
const { checkUrl } = useAcl();
let canceller = null; let canceller = null;
onMounted(() => { onMounted(() => {
@ -65,6 +68,7 @@ export function useArrayData(key = useRoute().meta.moduleName, userOptions) {
async function fetch({ append = false, updateRouter = true }) { async function fetch({ append = false, updateRouter = true }) {
if (!store.url) return; if (!store.url) return;
if (store.checkAcl) checkUrl(store.url, 'get', true);
cancelRequest(); cancelRequest();
canceller = new AbortController(); canceller = new AbortController();

View File

@ -54,7 +54,7 @@ export function useSession() {
headers: { Authorization: storage.getItem(key) }, headers: { Authorization: storage.getItem(key) },
}); });
} catch (error) { } catch (error) {
notify('errors.statusUnauthorized', 'negative'); notify('errors.statusUnauthorized', 'negative', 'account_circle_off');
} finally { } finally {
storage.removeItem(key); storage.removeItem(key);
} }

View File

@ -11,7 +11,7 @@ const route = useRoute();
const { t } = useI18n(); const { t } = useI18n();
const claimDevelopmentForm = ref(); const claimDevelopmentForm = ref({});
const claimReasons = ref([]); const claimReasons = ref([]);
const claimResults = ref([]); const claimResults = ref([]);
const claimResponsibles = ref([]); const claimResponsibles = ref([]);
@ -222,9 +222,16 @@ const columns = computed(() => [
ref="saveButtonRef" ref="saveButtonRef"
color="primary" color="primary"
icon="save" icon="save"
:disable="!claimDevelopmentForm?.hasChanges" :disable="
!claimDevelopmentForm?.hasChanges ||
!claimDevelopmentForm.hasWritePremission
"
@click="claimDevelopmentForm?.onSubmit" @click="claimDevelopmentForm?.onSubmit"
:title="t('globals.save')" :title="
claimDevelopmentForm.hasWritePremission
? t('globals.save')
: claimDevelopmentForm.accessDeniedTitle
"
/> />
</template> </template>
</CrudModel> </CrudModel>
@ -235,6 +242,11 @@ const columns = computed(() => [
icon="add" icon="add"
@keydown.tab.prevent="saveButtonRef.$el.focus()" @keydown.tab.prevent="saveButtonRef.$el.focus()"
@click="claimDevelopmentForm.insert()" @click="claimDevelopmentForm.insert()"
:disable="!claimDevelopmentForm.hasWritePremission"
:title="
!claimDevelopmentForm.hasWritePremission &&
claimDevelopmentForm.accessDeniedTitle
"
/> />
</QPageSticky> </QPageSticky>
</template> </template>

View File

@ -17,6 +17,7 @@ export const useArrayDataStore = defineStore('arrayDataStore', () => {
searchUrl: 'params', searchUrl: 'params',
navigate: null, navigate: null,
page: 1, page: 1,
checkAcl: true,
}; };
function get(key) { function get(key) {

9
src/utils/errors.js Normal file
View File

@ -0,0 +1,9 @@
class AccessError extends Error {
constructor(details, message) {
super(message ?? 'Access denied');
this.name = AccessError.name;
this.details = details;
}
}
export { AccessError };