0
0
Fork 0
mirror of https://github.com/verdigado/organization_folders.git synced 2024-11-23 13:10:28 +01:00

initial commit of GUI

This commit is contained in:
Jonathan Treffler 2024-11-18 18:32:34 +01:00
parent b64ae41cd0
commit f07b9953e3
20 changed files with 1416 additions and 0 deletions

70
img/deny.svg Normal file
View file

@ -0,0 +1,70 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
version="1.1"
viewbox="0 0 16 16"
width="16"
height="16"
id="svg3791"
sodipodi:docname="deny.svg"
inkscape:version="0.92.4 5da689c313, 2019-01-14">
<metadata
id="metadata3797">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<defs
id="defs3795" />
<sodipodi:namedview
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1"
objecttolerance="10"
gridtolerance="10"
guidetolerance="10"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:window-width="1920"
inkscape:window-height="1017"
id="namedview3793"
showgrid="false"
inkscape:zoom="18.296388"
inkscape:cx="-13.154265"
inkscape:cy="1.9505725"
inkscape:window-x="0"
inkscape:window-y="26"
inkscape:window-maximized="1"
inkscape:current-layer="g3789" />
<g
stroke-width="2"
stroke="#000"
stroke-linecap="round"
fill="none"
id="g3789">
<path
d="M 11.678082,4.5280679 4.5924509,11.613699"
id="path3787"
inkscape:connector-curvature="0"
style="stroke-width:2;stroke-miterlimit:4;stroke-dasharray:none" />
<ellipse
id="path3993"
cx="7.9999995"
cy="8"
rx="5.9096346"
ry="5.9096351"
style="stroke-width:2;stroke-miterlimit:4;stroke-dasharray:none" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

59
src/Header.vue Normal file
View file

@ -0,0 +1,59 @@
<script setup>
import { ref, inject, watch, computed, nextTick } from "vue";
import NcButton from "@nextcloud/vue/dist/Components/NcButton.js";
import NcLoadingIcon from "@nextcloud/vue/dist/Components/NcLoadingIcon.js";
import FolderCog from "vue-material-design-icons/FolderCog.vue";
import router from "./router.js";
import { useCurrentDirStore } from "./stores/current-dir.js";
import Modal from "./Modal.vue";
console.log("router loggg", router, router.currentRoute);
const currentDir = useCurrentDirStore();
const modalOpen = ref(false);
function openModal() {
if(currentDir.userManagerPermissions) {
if(currentDir.organizationFolderResourceId) {
router.push({
path: '/resource/' + currentDir.organizationFolderResourceId
});
} else {
router.push({
path: '/organizationFolder/' + currentDir.organizationFolderId
});
}
modalOpen.value = true;
}
}
</script>
<template>
<div v-if="currentDir.userManagerPermissions" class="toolbar">
<NcButton :disabled="currentDir.loading"
type="primary"
@click="openModal">
<template #icon>
<NcLoadingIcon v-if="currentDir.loading" />
<FolderCog v-else :size="20" />
</template>
Ordner und Berechtigungen Verwalten
</NcButton>
<Modal :open.sync="modalOpen" />
</div>
</template>
<style scoped>
.spacer {
flex-grow: 1;
}
.toolbar {
margin: 15px;
display: flex;
justify-content: space-between;
}
</style>

114
src/Modal.vue Normal file
View file

@ -0,0 +1,114 @@
<script setup>
import { defineProps, ref } from "vue";
import { useRouter, useRoute } from 'vue2-helpers/vue-router';
import NcModal from "@nextcloud/vue/dist/Components/NcModal.js";
const props = defineProps({
open: {
type: Boolean,
required: true,
},
});
const emit = defineEmits(["update:open"]);
const closeDialog = () => {
emit("update:open", false)
};
const router = useRouter();
const route = useRoute();
const currentView = ref(null);
</script>
<template>
<NcModal v-if="props.open"
size="large"
class="organizationfolders-dialog"
label-id="Organization Folder Management"
:out-transition="true"
:has-next="false"
:has-previous="false"
@close="closeDialog">
<router-view />
</NcModal>
</template>
<style>
.organizationfolders-dialog .modal-container {
width: unset !important;
height: 90%;
}
.material_you .list-item, .material_you.app-navigation-entry > .app-navigation-entry-button {
margin-bottom: 6px !important;
border-radius: 24px !important;
background-color: var(--color-background-dark)
}
.app-navigation-entry.material_you > .app-navigation-entry-button {
line-height: 60px;
}
.app-navigation-entry.material_you > .app-navigation-entry-button > .app-navigation-entry-icon {
width: 60px;
height: 60px;
flex-basis: 60px;
background-size: 44px 44px;
background-position: center center;
}
.app-navigation-entry.material_you > .app-navigation-entry-button > .app-navigation-new-item__name {
padding-left: 7px;
}
.material_you .list-item {
--default-clickable-area: 60px;
}
.material_you .list-item:hover, .material_you .list-item:focus, .material_you.app-navigation-entry:hover > .app-navigation-entry-button, .material_you.app-navigation-entry:focus > .app-navigation-entry-button {
background-color: var(--color-primary-light-hover) !important;
}
.material_you .list-item {
-webkit-box-sizing: border-box;
-moz-box-sizing: border-box;
box-sizing: border-box;
}
.material_you.list-item__wrapper.listItemSelectable:not(.selected) .list-item {
border: 3px solid transparent;
}
.material_you.list-item__wrapper.listItemSelectable.selected .list-item {
/*background-color: var(--color-primary-light) !important;*/
border: 3px solid var(--color-primary);
}
.material_you .list-item:hover .list-item-content__main .list-item-content__name {
color: var(--color-primary-light-text);
}
.material_you .list-item:hover .list-item__anchor > .material-design-icon svg {
fill: var(--color-primary-light-text);
}
.material_you .app-navigation-entry-div {
padding: 8px !important;
}
.material_you.app-navigation-entry:hover {
background-color: transparent !important;
}
.material_you .app-navigation-new-item__title {
font-weight: bold;
font-size: var(--default-font-size);
}
/* For divs required for vue, but irrelevant in the layout */
.ignoreForLayout {
display: contents;
}
</style>

147
src/ModalView.vue Normal file
View file

@ -0,0 +1,147 @@
<script setup>
import { ref, computed, watch, reactive, nextTick } from "vue";
import NcButton from "@nextcloud/vue/dist/Components/NcButton.js";
import KeyboardBackspace from "vue-material-design-icons/KeyboardBackspace.vue";
import NcLoadingIcon from "@nextcloud/vue/dist/Components/NcLoadingIcon.js";
const props = defineProps({
title: {
type: String,
required: true,
},
loading: {
type: Boolean,
default: false,
},
hasBackButton: {
type: Boolean,
default: false,
},
hasNextStepButton: {
type: Boolean,
default: false,
},
hasLastStepButton: {
type: Boolean,
default: false,
},
nextStepButtonEnabled: {
type: Boolean,
default: false,
},
lastStepButtonEnabled: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(["back-button-pressed", "next-step-button-pressed", "last-step-button-pressed"]);
const backButtonPressed = () => {
emit("back-button-pressed");
};
const nextStepButtonPressed = () => {
emit("next-step-button-pressed");
};
const lastStepButtonPressed = () => {
emit("last-step-button-pressed");
};
</script>
<template>
<div class="modal__content">
<div class="modal__title">
<NcButton
type="secondary"
class="btn-back"
aria-label="Zurück"
@click="backButtonPressed">
<template #icon>
<KeyboardBackspace />
</template>
</NcButton>
<h1>{{ title }}</h1>
</div>
<div v-if="loading" class="modal__loading">
<NcLoadingIcon :size="64" />
</div>
<div v-if="!loading" class="modal__main ignoreForLayout">
<slot></slot>
</div>
<div v-if="!loading && (hasLastStepButton || nextStepButtonEnabled)" class="modal__footer">
<NcButton
:style="{visibility: hasLastStepButton ? 'visible' : 'hidden'}"
type="secondary"
:disabled="!lastStepButtonEnabled"
aria-label="Zurück"
@click="lastStepButtonPressed">
Zurück
</NcButton>
<NcButton
v-if="hasNextStepButton"
type="primary"
:disabled="!nextStepButtonEnabled"
aria-label="Weiter"
@click="nextStepButtonPressed">
Weiter
</NcButton>
</div>
</div>
</template>
<style scoped>
.modal__title {
margin-bottom: 16px;
flex-grow: 0;
}
.modal__title h1 {
text-align: center;
font-size: 1.6rem;
font-weight: bold;
}
.modal__footer{
margin-top: 16px;
height: 50px;
flex-grow: 0;
}
.modal__footer__restore {
display: flex;
flex-direction: row;
justify-content: space-between;
}
.modal__content {
padding: 50px;
min-width: 75vw;
height: calc(100% - 100px);
overflow: scroll;
display: flex;
flex-direction: column;
}
.modal__loading {
padding: 50px;
min-width: 75vw;
height: calc(100% - 100px);
overflow: hidden;
display: flex;
justify-content: center;
}
.btn-back {
position: absolute;
}
</style>
<style>
.organizationfolders-dialog .modal-container {
width: unset !important;
height: 90%;
}
</style>

73
src/api.js Normal file
View file

@ -0,0 +1,73 @@
import axios from "@nextcloud/axios"
import { generateUrl } from "@nextcloud/router"
/**
* @typedef {{
* id: number
* type: string
* organizationFolderId: number
* name: string
* parentResource: number
* active: bool
* inheritManagers: bool
* membersAclPermission: number
* managersAclPermission: number
* inheritedAclPermission: number
* }} FolderResource
*
* @typedef {(FolderResource)} Resource
*
* @typedef {{
* type: number,
* id: string,
* }} Principal
*
* @typedef {{
* id: number
* resourceId: number
* permissionLevel: number
* principal: Principal,
* createdTimestamp: number,
* lastUpdatedTimestamp: number,
* }} ResourceMember
*
*/
axios.defaults.baseURL = generateUrl("/apps/organization_folders")
export default {
/**
*
* @param {number|string} resourceId Resource id
* @param {string} include
* @return {Promise<Resource>}
*/
getResource(resourceId, include = "model") {
return axios.get(`/resources/${resourceId}`, { params: { include } }).then((res) => res.data)
},
/**
* @param {number|string} resourceId Resource id
* @param {{
* name: string|undefined
* active: boolean|undefined
* inheritManagers: boolean|undefined
* membersAclPermission: number|undefined
* managersAclPermission: number|undefined
* inheritedAclPermission: number|undefined
* }} updateResourceDto UpdateResourceDto
* @return {Promise<Resource>}
*/
updateResource(resourceId, updateGroupDto) {
return axios.put(`/resources/${resourceId}`, { ...updateGroupDto }).then((res) => res.data)
},
/**
*
* @param {number|string} resourceId Resource id
* @return {Promise<Array<ResourceMember>>}
*/
getResourceMembers(resourceId) {
return axios.get(`/resources/${resourceId}/members`, {}).then((res) => res.data)
},
}

View file

@ -0,0 +1,92 @@
<script setup>
import { ref } from "vue";
import NcTextField from "@nextcloud/vue/dist/Components/NcTextField.js";
import NcModal from "@nextcloud/vue/dist/Components/NcModal.js";
const props = defineProps({
title: {
type: String,
default: "Löschen",
},
loading: {
type: Boolean,
default: false,
},
matchText: {
type: String,
default: "löschen",
},
});
const open = ref(false);
const confirmText = ref("");
const openDialog = () => {
open.value = true;
}
const closeDialog = () => {
open.value = false;
}
</script>
<template>
<div>
<slot name="activator" :open="openDialog">
<button type="button" @click="openDialog">
Löschen
</button>
</slot>
<NcModal v-if="open"
class="modal"
:out-transition="true"
:has-next="false"
:has-previous="false"
@close="closeDialog">
<div class="modal__content">
<div class="modal__title">
<h1>
{{ props.title }}
</h1>
</div>
<div>
<slot name="content" />
<p>
Gib hier als Bestätigung "<span style="user-select: all;">{{ props.matchText }}</span>" ein.
</p>
<NcTextField class="confirmText"
:value.sync="confirmText"
style=" --color-border-maxcontrast: #949494;" />
<slot name="delete-button" :close="closeDialog" :disabled="confirmText !== props.matchText">
<button type="button">
Löschen
</button>
</slot>
</div>
</div>
</NcModal>
</div>
</template>
<style scoped>
.confirmText {
margin: 1rem 0 1rem 0;
}
.modal__title {
margin-bottom: 16px;
height: 50px;
}
.modal__title h1 {
text-align: center;
font-size: 1.6rem;
font-weight: bold;
}
.modal__content {
margin: 50px;
min-height: 500px;
}
</style>

View file

@ -0,0 +1,162 @@
<script setup>
import NcEmptyContent from "@nextcloud/vue/dist/Components/NcEmptyContent.js"
import NcLoadingIcon from "@nextcloud/vue/dist/Components/NcLoadingIcon.js"
import NcActions from "@nextcloud/vue/dist/Components/NcActions.js"
import NcActionButton from "@nextcloud/vue/dist/Components/NcActionButton.js"
import NcButton from "@nextcloud/vue/dist/Components/NcButton.js"
import { showError } from "@nextcloud/dialogs"
//import MemberListNewItem from "./MemberListNewItem.vue"
import MemberListItem from "./MemberListItem.vue"
import Plus from "vue-material-design-icons/Plus.vue"
import Close from "vue-material-design-icons/Close.vue"
import HelpCircle from "vue-material-design-icons/HelpCircle.vue"
import api from "../../api.js"
import { ref } from 'vue';
const props = defineProps({
members: {
type: Array,
required: true,
},
});
const loading = ref(false);
const error = ref(undefined);
const newItemComponent = ref(null);
const addMenuOpen = ref(false);
const setNewItemComponent = (name) => {
this.newItemComponent.value = name
this.addMenuOpen.value = false
};
const deleteMember = async (memberId) => {
this.loading.value = true
try {
api.deleteGroupMember(this.groupId, memberId)
//this.members.value = this.members.filter((m) => m.id !== memberId)
} catch (err) {
showError(err.message)
} finally {
this.loading.value = false
}
};
const updateMember = async (memberId, changes) => {
this.loading.value = true
try {
const member = await api.updateGroupMember(this.groupId, memberId, changes)
this.members = this.members.map((m) => m.id === member.id ? member : m)
} catch (err) {
showError(err.message)
} finally {
this.loading.value = false
}
};
const addMember = async ({ mappingId, mappingType }) => {
this.loading.value = true
try {
const _member = await api.addGroupMember(this.groupId, {
mappingType,
mappingId,
type: "member",
})
this.members.push(_member)
this.setNewItemComponent(null)
} catch (err) {
showError(err.message)
} finally {
this.loading = false
}
};
</script>
<template>
<div>
<div class="title">
<h3>Mitglieder</h3>
<!--<NcActions :disabled="!!newItemComponent" type="secondary">
<template #icon>
<Plus :size="20" />
</template>
<NcActionButton icon="icon-group" close-after-click @click="setNewItemComponent('new_item')">
Benutzer/Gruppe hinzufügen
</NcActionButton>
<NcActionButton icon="icon-group" close-after-click @click="setNewItemComponent('new_role_item')">
Organisation Rolle hinzufügen
</NcActionButton>
</NcActions>-->
</div>
<!--<div v-if="newItemComponent" class="new-item">
<NcButton type="tertiary" @click="setNewItemComponent(null)">
<template #icon>
<Close />
</template>
</NcButton>
<MemberListNewItem v-if="newItemComponent === 'new_item'" :group-id="groupId" @selected="addMember" />
</div>-->
<table>
<thead style="display: contents;">
<tr>
<th />
<th>Name</th>
<th>
<div style="display: flex; align-items: center;">
<span>Typ</span>
<HelpCircle v-tooltip="'Für Admins gelten die oben ausgewählten Ordneradministrator*innen Berechtigungen, für Mitglieder die Ordnermitglieder Berechtigungen. Admins haben auf diese Einstellungen Zugriff.'" style="margin-left: 5px;" :size="15" />
</div>
</th>
<th>Aktion</th>
</tr>
</thead>
<tbody style="display: contents">
<tr v-if="loading">
<td colspan="4" style="grid-column-start: 1; grid-column-end: 5">
<NcLoadingIcon :size="50" />
</td>
</tr>
<tr v-if="!loading && !members.length">
<td colspan="4" style="grid-column-start: 1; grid-column-end: 5">
<NcEmptyContent title="Keine Gruppenmitglieder" />
</td>
</tr>
<MemberListItem v-for="member in members"
:key="member.id"
:member="member"
@update="updateMember"
@delete="deleteMember" />
</tbody>
</table>
</div>
</template>
<style scoped>
table {
width: 100%;
margin-bottom: 14px;
display: grid;
grid-template-columns: max-content minmax(30px, auto) max-content max-content;
}
table tr {
display: contents;
}
table td, table th {
padding: 8px;
}
.title {
display: flex;
justify-content: flex-start;
align-items: center;
margin-top: 24px;
}
h3 {
font-weight: bold;
margin-right: 24px;
}
.new-item {
display: flex;
}
</style>

View file

@ -0,0 +1,95 @@
<script setup>
import Delete from "vue-material-design-icons/Delete.vue"
import NcButton from "@nextcloud/vue/dist/Components/NcButton.js"
import NcAvatar from "@nextcloud/vue/dist/Components/NcAvatar.js"
import ChevronRight from "vue-material-design-icons/ChevronRight.vue"
import { computed } from "vue"
const props = defineProps({
member: {
type: Object,
required: true,
},
});
const friendlyNameParts = computed(() => props.member.principal.split(" / "));
const emit = defineEmits(["update", "delete"]);
const typeOptions = [
{ label: "Mitglied", value: 1 },
{ label: "Manager", value: 2 },
];
const onTypeSelected = (e) => {
emit("update", props.member.id, {
type: e.target.value,
})
};
const onDeleteClicked = (e) => {
emit("delete", props.member.id)
};
</script>
<template>
<tr>
<td>
<NcAvatar :user="props.member.type === 1 ? props.member.principal : undefined"
:disabled-menu="true"
:disabled-tooltip="true"
:icon-class="props.member.type === 2 ? 'icon-group' : undefined" />
</td>
<td>
<div class="friendlyNameParts">
<div v-for="(friendlyNamePart, index) of friendlyNameParts" :key="'breadcrumb-' + friendlyNamePart" class="friendlyNamePartDiv">
<p v-tooltip="friendlyNamePart" class="friendlyNamePartP">
{{ friendlyNamePart }}
</p>
<ChevronRight v-if="index !== friendlyNameParts.length - 1" :size="20" />
</div>
</div>
</td>
<td>
<select :value="props.member.permissionLevel" @input="onTypeSelected">
<option v-for="{ label, value} in typeOptions" :key="value" :value="value">
{{ label }}
</option>
</select>
</td>
<td>
<NcButton type="tertiary-no-background" @click="onDeleteClicked">
<template #icon>
<Delete :size="20" />
</template>
</NcButton>
</td>
</tr>
</template>
<style scoped>
td {
padding: 8px;
}
.friendlyNameParts {
display: inline-flex;
max-width: 100%;
overflow-x: clip;
}
.friendlyNamePartP {
white-space: nowrap;
overflow: hidden;
}
.friendlyNamePartP:not(:last-child) {
text-overflow: ellipsis;
}
.friendlyNamePartDiv {
display: inline-flex;
min-width: 20px;
}
.friendlyNamePartDiv:last-child {
flex-shrink: 0;
}
</style>

View file

@ -0,0 +1 @@
export { default } from "./MemberList.vue"

View file

@ -0,0 +1,93 @@
<script setup>
import { computed } from "vue";
import PermissionsInputRow from "./PermissionsInputRow.vue";
const props = defineProps({
resource: {
type: Object,
required: true,
},
})
const emit = defineEmits(["permissionUpdated"]);
const permissionGroups = computed(() => {
return [
{
field: "managersAclPermission",
label: "Resourcenadministrator*innen",
value: props.resource.managersAclPermission,
mask: 31,
},
{
field: "membersAclPermission",
label: "Resourcenmitglieder",
value: props.resource.membersAclPermission,
mask: 31,
},
{
field: "inheritedAclPermission",
label: "Vererbte Berechtigungen",
value: props.resource.inheritedAclPermission,
mask: 31,
},
]
});
const permissionUpdated = async (field, value) => {
emit("permissionUpdated", { field, value });
}
</script>
<template>
<table>
<thead>
<tr>
<th />
<th v-tooltip="t('groupfolders', 'Read')" class="state-column">
{{ t('groupfolders', 'Read') }}
</th>
<th v-tooltip="t('groupfolders', 'Write')" class="state-column">
{{ t('groupfolders', 'Write') }}
</th>
<th v-tooltip="t('groupfolders', 'Create')" class="state-column">
{{ t('groupfolders', 'Create') }}
</th>
<th v-tooltip="t('groupfolders', 'Delete')" class="state-column">
{{ t('groupfolders', 'Delete') }}
</th>
<th v-tooltip="t('groupfolders', 'Share')" class="state-column">
{{ t('groupfolders', 'Share') }}
</th>
</tr>
</thead>
<tbody>
<PermissionsInputRow v-for="{ field, label, mask, value} in permissionGroups"
:key="field"
:label="label"
:mask="mask"
:value="value"
@change="(val) => permissionUpdated(field, val)" />
</tbody>
</table>
</template>
<style scoped>
table {
width: 100%;
margin-bottom: 14px;
}
table td, table th {
padding: 0
}
.state-column {
text-align: center;
width: 44px !important;
padding: 3px;
}
thead .state-column {
text-overflow: ellipsis;
overflow: hidden;
}
</style>

View file

@ -0,0 +1,82 @@
<script setup>
import { computed } from "vue";
import { calcBits, toggleBit } from "../../helpers/permission-helpers.js";
const props = defineProps({
label: {
type: String,
default: "",
},
mask: {
type: Number,
default: 31,
},
value: {
type: Number,
default: 0,
},
})
const emit = defineEmits(["change"]);
const calcBitButtonProps = (bitName, bitState) => {
const states = {
INHERIT_DENY: {
tooltipText: t("groupfolders", "Denied (Inherited permission)"),
className: "icon-deny inherited",
},
INHERIT_ALLOW: {
tooltipText: t("groupfolders", "Allowed (Inherited permission)"),
className: "icon-checkmark inherited",
},
SELF_DENY: {
tooltipText: t("groupfolders", "Denied"),
className: "icon-deny",
},
SELF_ALLOW: {
tooltipText: t("groupfolders", "Allowed"),
className: "icon-checkmark",
},
}
return {
...states[bitState],
bitName,
}
};
const bitButtonProps = computed(() => Object.entries(calcBits(props.value, props.mask)).map(([bitName, { state }]) => calcBitButtonProps(bitName, state)));
const onClick = (bitName) => emit("change", toggleBit(props.value, bitName));
</script>
<template>
<tr>
<td v-tooltip="props.label">
{{ props.label }}
</td>
<td v-for="({ bitName, className, tooltipText }) in bitButtonProps" :key="bitName">
<button v-tooltip="tooltipText"
:class="className"
@click="() => onClick(bitName)" />
</td>
</tr>
</template>
<style scoped>
button {
height: 24px;
border-color: transparent;
}
button:hover {
height: 24px;
border-color: var(--color-primary, #0082c9);
}
.icon-deny {
background-image: url('../../../img/deny.svg');
}
.inherited {
opacity: 0.5;
}
</style>

View file

@ -0,0 +1 @@
export { default } from "./Permissions.vue"

69
src/header.js Normal file
View file

@ -0,0 +1,69 @@
import Vue from "vue";
import { Header } from '@nextcloud/files';
import { createPinia } from "pinia";
import { subscribe } from '@nextcloud/event-bus';
import router from "./router.js";
import HeaderComponent from "./Header.vue";
import { useCurrentDirStore } from "./stores/current-dir.js";
let vm = null;
let currentFolderFileid = null;
const pinia = createPinia();
const OrganizationFoldersHeader = new Header({
id: 'organization_folders',
order: 2,
enabled(_, view) {
return view.id === 'files' || view.id === 'favorites';
},
async render(el, folder, view) {
if(!vm) {
el.id = "organization_folders";
vm = new Vue({
el,
router,
pinia,
render: h => h(HeaderComponent),
});
} else {
// the outer vue instance calling will have replaced el, so we need to "re-mount" our vue instance
el.replaceWith(vm.$el);
}
const currentDir = useCurrentDirStore();
currentDir.updatePath(folder?.path);
currentFolderFileid = folder?.fileid;
},
updated(folder) {
const currentDir = useCurrentDirStore();
currentDir.updatePath(folder?.path);
currentFolderFileid = folder?.fileid;
},
})
// Handle empty folders seperately, because Headers are not rendered in this case :/
subscribe("files:list:updated", ({view, folder, contents}) => {
if(contents.length === 0) {
// only re-render, if open folder has changed
if(folder && currentFolderFileid !== folder.fileid) {
const fileListHeader = document.querySelector(".app-files .files-list__header");
const vueContainer = document.createElement("div");
vueContainer.style.width = "100%";
console.log("vueContainer.nextElementSibling", fileListHeader.nextElementSibling);
fileListHeader.parentNode.insertBefore(vueContainer, fileListHeader.nextElementSibling);
OrganizationFoldersHeader.render(vueContainer, folder, view);
}
}
});
export default OrganizationFoldersHeader;

View file

@ -0,0 +1,12 @@
/**
*
* @param {number} bytes filesize in bytes
* @return {string} file size in appropriate unit
*/
export function bytesToSize(bytes) {
const sizes = ["Bytes", "KB", "MB", "GB", "TB"]
if (bytes === 0) return "0 Byte"
const i = parseInt(Math.floor(Math.log(bytes) / Math.log(1024)))
return Math.round(bytes / Math.pow(1024, i), 2) + " " + sizes[i]
}

View file

@ -0,0 +1,52 @@
/**
* bit names from least to most significant bit
*/
const bitNames = ["READ", "UPDATE", "CREATE", "DELETE", "SHARE"]
/**
* mask value
* 0 0 INHERIT_DENY: Denied (Inherited permission)
* 0 1 INHERIT_ALLOW: Allowed (Inherited permission)
* 1 0 SELF_DENY: Denied
* 1 1 SELF_ALLOW: Allowed
*/
const bitStates = ["INHERIT_DENY", "INHERIT_ALLOW", "SELF_DENY", "SELF_ALLOW"]
/**
* @param {number} value integer value
* @param {number} bit the nth bit to check, zero indexed
* @return {boolean} if bit is set
*/
const bitValue = (value, bit) => (value >> bit) % 2 !== 0
const bitState = (value, mask, bit) => {
const valueBit = bitValue(value, bit)
const maskBit = bitValue(mask, bit)
const i = valueBit | (maskBit << 1)
return bitStates[i]
}
/**
*
* @param {number} value permission value 0 - 31
* @param {string} bitName READ UPDATE CREATE DELETE SHARE
*/
export function toggleBit(value, bitName) {
return value ^ (1 << bitNames.indexOf(bitName))
}
export const isBitSet = (value, bitName) => {
return bitValue(value, bitNames.indexOf(bitName))
}
/**
* @param {number} value
* @param {number} mask
*/
export function calcBits(value, mask) {
const maskedValue = value & mask
return Object.fromEntries(bitNames.map((key, index) => ([key, {
value: bitValue(maskedValue, index),
state: bitState(value, mask, index),
}])))
}

10
src/helpers/validation.js Normal file
View file

@ -0,0 +1,10 @@
/**
*
* @param {string} str
*/
export function validResourceName(str) {
/* eslint-disable no-useless-escape */
const specialChars = /[`!@#$%^()+=\[\]{};'"\\|,.<>\/?~]/
return !specialChars.test(str)
}

40
src/main.js Normal file
View file

@ -0,0 +1,40 @@
import Vue from "vue";
import { PiniaVuePlugin } from "pinia";
import { registerFileListHeaders } from '@nextcloud/files';
import { translate as t, translatePlural as n } from "@nextcloud/l10n";
import { generateFilePath } from "@nextcloud/router";
import Tooltip from "@nextcloud/vue/dist/Directives/Tooltip.js";
import { initFilesClient } from "./davClient.js";
import Header from "./header.js";
import api from "./api.js";
import '@nextcloud/dialogs/style.css';
// eslint-disable-next-line
__webpack_public_path__ = generateFilePath(appName, '', 'js/');
// Adding translations to the whole app
Vue.mixin({
methods: {
t,
n,
},
});
// Recommendation by @nextcloud/vue
Vue.prototype.OC = window.OC;
Vue.prototype.OCA = window.OCA;
Vue.directive("tooltip", Tooltip);
// Pinia
Vue.use(PiniaVuePlugin);
window.api = api;
window.addEventListener('DOMContentLoaded', () => {
initFilesClient(OC.Files.getClient());
});
registerFileListHeaders(Header);

24
src/router.js Normal file
View file

@ -0,0 +1,24 @@
import Vue from "vue";
import Router from "vue-router";
import ResourceSettings from "./views/ResourceSettings.vue";
Vue.use(Router);
const router = new Router({
mode: 'abstract',
routes: [
{
path: "/resource/:resourceId",
name: "resource-settings",
component: ResourceSettings,
props: (route) => (
{
resourceId: Number.parseInt(route.params.resourceId, 10) || undefined,
}
),
},
],
});
export default router;

55
src/stores/current-dir.js Normal file
View file

@ -0,0 +1,55 @@
import { defineStore } from "pinia";
import { computed } from "vue";
import { getFolderProperties } from "../davClient.js";
import api from "../api.js";
export const useCurrentDirStore = defineStore("currentDir", {
state: () => ({
loading: false,
path: "",
organizationFolderId: null,
organizationFolderResourceId: null,
userManagerPermissions: null,
}),
actions: {
/**
* set the path of the current directory and fetch organization folders info from dav api
*
* @param {string} path current path
*/
async updatePath(path) {
this.loading = true;
this.path = path
let { fileInfo } = await getFolderProperties(path)
.catch(() => {
this.organizationFolderId = false;
this.organizationFolderResourceId = false;
this.userManagerPermissions = false;
this.loading = false;
});
console.log("fileInfo", fileInfo);
if(fileInfo) {
this.organizationFolderId = fileInfo.organizationFolderId;
this.organizationFolderResourceId = fileInfo.organizationFolderResourceId;
this.userManagerPermissions = fileInfo.userManagerPermissions;
} else {
this.organizationFolderId = false;
this.organizationFolderResourceId = false;
this.userManagerPermissions = false;
}
this.loading = false;
},
async fetchCurrentResource() {
if(this.organizationFolderResourceId) {
return await api.getResource(this.organizationFolderResourceId);
} else {
return false;
}
}
},
})

View file

@ -0,0 +1,165 @@
<script setup>
import { ref, watch, computed } from "vue";
import { loadState } from '@nextcloud/initial-state';
import { NcLoadingIcon, NcCheckboxRadioSwitch, NcButton, NcTextField } from '@nextcloud/vue';
import BackupRestore from "vue-material-design-icons/BackupRestore.vue";
import Delete from "vue-material-design-icons/Delete.vue";
import MemberList from "../components/MemberList/index.js";
import Permissions from "../components/Permissions/index.js";
import ConfirmDeleteDialog from "../components/ConfirmDeleteDialog.vue";
import ModalView from '../ModalView.vue';
import api from "../api.js";
import { validResourceName } from "../helpers/validation.js";
const props = defineProps({
resourceId: {
type: Number,
required: true,
},
});
const resource = ref(null);
const loading = ref(false);
const resourceActiveLoading = ref(false);
const currentResourceName = ref(false);
const resourceNameValid = computed(() => {
return validResourceName(currentResourceName.value);
});
const saveName = async () => {
resource.value = await api.updateResource(resource.value.id, { name: currentResourceName.value });
};
watch(() => props.resourceId, async (newResourceId) => {
loading.value = true;
resource.value = await api.getResource(newResourceId, "model+members");
currentResourceName.value = resource.value.name;
loading.value = false;
}, { immediate: true });
const saveActive = async (active) => {
resourceActiveLoading.value = true;
resource.value = await api.updateResource(resource.value.id, { active });
resourceActiveLoading.value = false;
};
const savePermission = async ({ field, value }) => {
resource.value = await api.updateResource(resource.value.id, {
[field]: value,
});
};
const switchToSnapshotRestoreView = () => {
};
const snapshotIntegrationActive = loadState('organization_folders', 'snapshot_integration_active', false);
</script>
<template>
<ModalView :has-back-button="true" :has-next-step-button="false" :has-last-step-button="false" :title="'Resource Settings'" :loading="loading" v-slot="">
<h3>Eigenschaften</h3>
<div class="name-input-container">
<NcTextField :value.sync="currentResourceName"
:error="!resourceNameValid"
:label-visible="!resourceNameValid"
:label-outside="true"
:helper-text="resourceNameValid ? '' : 'Ungültiger Name'"
label="Name"
:show-trailing-button="currentResourceName !== resource.name"
trailing-button-icon="arrowRight"
style=" --color-border-maxcontrast: #949494;"
@trailing-button-click="saveName"
@blur="() => currentResourceName = currentResourceName.trim()"
@keyup.enter="saveName" />
</div>
<h3>Berechtigungen</h3>
<Permissions :resource="resource" @permissionUpdated="savePermission" />
<MemberList :members="resource?.members" />
<h3>Einstellungen</h3>
<div class="settings-group">
<NcButton v-if="snapshotIntegrationActive" @click="switchToSnapshotRestoreView">
<template #icon>
<BackupRestore />
</template>
Aus Backup wiederherstellen
</NcButton>
<div class="resource-active-button">
<NcCheckboxRadioSwitch :checked="resource.active"
:loading="resourceActiveLoading"
type="checkbox"
@update:checked="saveActive">
Resource aktiv
</NcCheckboxRadioSwitch>
</div>
<ConfirmDeleteDialog title="Ordner löschen"
:loading="loading"
button-label="Ordner löschen"
:match-text="resource.name">
<template #activator="{ open }">
<NcButton v-tooltip="resource.active ? 'Nur deaktivierte Resourcen können gelöscht werden' : undefined"
style="height: 52px;"
:disabled="resource.active"
type="error"
@click="open">
Gruppe löschen
</NcButton>
</template>
<template #content>
<p style="margin: 1rem 0 1rem 0">
Du bist dabei den Ordner {{ resource.name }} und den Inhalt komplett zu löschen.
</p>
</template>
<template #delete-button="{ close, disabled }">
<NcButton type="warning"
:disabled="disabled || loading"
:loading="loading"
@click="() => deleteResource(close)">
<template #icon>
<NcLoadingIcon v-if="loading" />
<Delete v-else :size="20" />
</template>
Gruppe löschen
</NcButton>
</template>
</ConfirmDeleteDialog>
</div>
</ModalView>
</template>
<style scoped>
.name-input-group {
display: flex;
align-items: flex-end;
max-width: 500px;
}
.settings-group {
display: flex;
margin-top: 5px;
}
.settings-group > :not(:last-child) {
margin-right: 20px;
}
.resource-active-button >>> .checkbox-radio-switch__label {
/* Add primary background color like other buttons */
background-color: var(--color-primary-light);
}
label {
display: block;
}
h3 {
font-weight: bold;
margin-top: 24px;
margin-bottom: 0;
}
</style>