diff options
author | Christopher Ng <chrng8@gmail.com> | 2023-07-07 11:31:23 -0700 |
---|---|---|
committer | Christopher Ng <chrng8@gmail.com> | 2023-07-12 17:30:11 -0700 |
commit | cbfe0c67e9072f18bb40b795032d47f1639decb9 (patch) | |
tree | 8090c18f58dd0f4794f0265907c5edc218af44df | |
parent | 97a93c73cec09a72cf035e9f70a62d4396b09e82 (diff) | |
download | nextcloud-server-cbfe0c67e9072f18bb40b795032d47f1639decb9.tar.gz nextcloud-server-cbfe0c67e9072f18bb40b795032d47f1639decb9.zip |
enh(a11y): Users table
Signed-off-by: Christopher Ng <chrng8@gmail.com>
-rw-r--r-- | apps/settings/css/settings.css.map | 2 | ||||
-rw-r--r-- | apps/settings/css/settings.scss | 278 | ||||
-rw-r--r-- | apps/settings/src/components/UserList.vue | 382 | ||||
-rw-r--r-- | apps/settings/src/components/Users/NewUserModal.vue | 32 | ||||
-rw-r--r-- | apps/settings/src/components/Users/UserListFooter.vue | 126 | ||||
-rw-r--r-- | apps/settings/src/components/Users/UserListHeader.vue | 150 | ||||
-rw-r--r-- | apps/settings/src/components/Users/UserRow.vue | 785 | ||||
-rw-r--r-- | apps/settings/src/components/Users/UserRowActions.vue | 48 | ||||
-rw-r--r-- | apps/settings/src/components/Users/UserRowSimple.vue | 185 | ||||
-rw-r--r-- | apps/settings/src/components/Users/shared/styles.scss | 110 | ||||
-rw-r--r-- | apps/settings/src/mixins/UserRowMixin.js | 38 | ||||
-rw-r--r-- | apps/settings/src/store/users.js | 23 | ||||
-rw-r--r-- | apps/settings/src/utils/userUtils.ts | 40 | ||||
-rw-r--r-- | apps/settings/src/views/Users.vue | 60 |
14 files changed, 1223 insertions, 1036 deletions
diff --git a/apps/settings/css/settings.css.map b/apps/settings/css/settings.css.map index 04c837346be..188946be9cb 100644 --- a/apps/settings/css/settings.css.map +++ b/apps/settings/css/settings.css.map @@ -1 +1 @@ -{"version":3,"sourceRoot":"","sources":["settings.scss","../../../core/css/functions.scss"],"names":[],"mappings":"AAOC,0BACC,WAKF,OACC,WAID,4BC+CC,2CD3CD,mBC2CC,kDDvCD,qBCuCC,yCDnCD,0BCmCC,wCD/BD,oEC+BC,2CD3BD,oCACC,oBACA,0BACA,+BACA,mBAGD,4BACC,oBACA,kCAGD,yBACC,WAIA,wCACC,kBACA,yDACC,gBAIA,mOACC,WAKH,uCACC,aAGD,sCACC,WAED,uDACC,WAKD,gBACC,WAIF,mBACC,aACA,aACA,iBACA,4DACA,qBAEA,4BACC,kBACA,SAEA,+BACC,mBAIA,qCACC,iBAKH,kCACC,iBACA,mBACA,gBAGD,mGACC,4BACA,kBACA,WAMF,oBACC,kBACA,wCACC,0BACA,8CACC,oDAKH,aACC,qBACA,YACA,kBACA,8CACA,WACA,wCACA,8CACA,6CAEA,0DAGC,mCACA,mDACA,qDAGD,uBACC,kBACA,yBAIF,6BACC,oBACA,kCAEA,mCACC,WAIA,oCACC,kBACA,oBACA,iBACA,2BACA,WACA,mBACA,QAEA,0CACC,mBACA,uBACA,gBAKD,gIACC,kBACA,UACA,UACA,oBACA,YAKH,qCACC,kBACA,UACA,MACA,SAEA,yCACC,qBAIF,4CACC,eAGD,4CACC,sBACA,WACA,YACA,YAMF,qBACC,aACA,WACA,SACA,YAEA,uBACC,aAGD,uCACC,sBACA,cACA,yBAIF,iBACC,kBACA,eACA,WACA,YACA,aACA,SACA,gBACA,YAEA,8CAEC,+CACA,wCAEA,0FACC,WAIF,uCACC,kBACA,qBACA,gCACA,WACA,eAEA,wDACC,qBACA,sBACA,eAIF,sCACC,SAGC,4DAEC,iBACA,kBAEA,kFACC,YAGD,mEACC,oDAEA,kFACC,iBAIF,qEACC,WAEA,eAEA,uEACC,eAQN,gBACC,YAIA,2BACC,kCAGD,mBACC,YAIF,sCAEC,aAGD,eACC,WAGD,YACC,qBAIA,aACC,WACA,yBACA,YAGD,WACC,WACA,yBACA,YAMD,oBACC,iBAGD,iBACC,eAKD,iCACC,aACA,eACA,sBACA,SACA,gDACC,aACA,eACA,sBACA,sDACC,oBAIF,kGACC,cACA,YACA,gBAKA,iEACC,kBACA,UAED,+EACC,oBACA,eACA,wBACA,UAIF,wCACC,WAGD,iDACC,qBAGD,sDACC,kBACA,OACA,WACA,0BACA,eACA,gBACA,WAQF,oBACC,gBAGD,wBACC,iBAGD,oDACC,WACA,YACA,mBACA,wCAOD,oBACC,UACA,cACA,gBACA,uBAGD,2BACC,UAKD,oCAEC,cAKD,wEAEC,aAIF,gBACC,kBACA,QACA,QAEA,sBACC,YAGD,sBACC,iBAKF,WACC,WAEA,cACC,WACA,WACA,4CACA,eACA,kBACA,gBACA,mBAGD,cACC,4CACA,eACA,kBACA,gBACA,mBAKD,gBACC,kBACA,cACA,eACA,uBACA,gBAGD,wBACC,kBAEA,gCACC,kBAIF,sCACC,kBAGD,sDAEC,cACA,eACA,eAEA,0EACC,UACA,qBACA,uBACA,gBAIF,8BACC,eAGD,kCACC,mBACA,cAIF,2BACC,mBAID,4BACC,WACA,SACA,QAGD,0BACC,mBAGD,SACC,gBAKA,oBACC,mBACA,iBACA,WAGD,gCACC,kBAIA,gGACC,cAIF,4BACC,gBAGD,iCACC,gBAGD,8BACC,oCAIF,aACC,gBACA,iBACA,oCAGD,aACC,oCAIA,gBACC,oCACA,+BACA,+CACA,mCACA,gBAGD,aACC,aACA,YACA,mBAGD,qBACC,gCACA,+BACA,kBAGD,sBACC,kCACA,gCACA,+BACA,kBACA,2BAIF,WACC,kBACA,QACA,WAIA,qCACC,aAMD,0BACC,SAGD,2BACC,cACA,aAGD,yDACC,eAGD,8BACC,WAGD,qDACC,WACA,aACA,qBACA,WAGD,kCACC,YAGD,0BACC,gBAMA,4FAEC,qBACA,WACA,YACA,kBACA,WAIF,0CACC,YACA,WAEA,yGAEC,mBAGD,2DACC,YAIF,uFACC,oCAGD,iDACC,cAGD,kDACC,cAGD,sCACC,kBACA,MACA,QACA,aACA,WACA,UACA,WACA,YAGD,wCACC,aACA,mBAEA,oDACC,YAIF,yCACC,0BACA,iBAGD,iOAKC,cAIF,2CACC,gBACC,UAED,kCACC,WAIF,2CACC,gBACC,UAED,kCACC,WAIF,2CACC,gBACC,UAED,kCACC,WAIF,0CACC,gBACC,UAED,kCACC,YAIF,2CACC,gBACC,WAIF,0CACC,gBACC,YAKF,0CAEE,kEACC,yBAKH,0CACC,iCACC,yBAIF,SACC,gBAEA,0BACC,4CAID,YACC,mBAEA,uBACC,iBACA,2BACA,qBAKH,iBACC,cACA,yBAGD,WACC,kBACA,aACA,UACA,gBAGD,6CACC,qBAGD,0DACC,WACA,cACA,eACA,WAGD,2BACC,WACA,kBACA,QAGD,iBACC,WAGD,gBACC,mBAKD,kBACC,0BAGD,kBACC,cAGD,sBACC,mBACA,wBACA,2BAGD,WAyGC,aACA,eACA,yBAvGA,oBACC,eAGD,0BACC,wBAGD,gCACC,iBAGD,oBACC,OAfgB,KAgBhB,QAjBiB,IAmBjB,aAlBgB,KAmBhB,WACA,8CACA,gBACA,MACA,UACA,aACA,mBAGD,qBAQC,oBAPA,0CACC,cACA,WACA,YACA,WAjCe,KAsChB,8BACC,kBACA,UACA,SAEA,gCACC,mBACA,eACA,sBACA,WACA,4CACA,YACA,sBAGD,uCACC,8CAKF,oCACC,aAEA,0CACC,iBAIF,gCACC,WACA,YACA,iBAGD,kGAEC,eACA,WACA,YACA,WACA,sBACA,qBAGD,8BACC,iBAEA,kDACC,qBACA,QACA,kBAKH,+CACC,kBACA,YAEA,WACA,YACA,WAOD,kBACC,aAGD,oBACC,kBACA,cAEA,gCACC,cACA,aAGD,0BACC,8CAKD,8BACC,cAGD,+BACC,gBAGD,+BACC,mBAEA,oEACC,kBAKD,8DACC,iBAKD,oEACC,kBAMH,wBACC,kBACA,kBAEA,4BACC,mBACA,YAGD,2BACC,mBACA,kBACA,iBACA,iBAEA,mCACC,kBACA,SACA,iBAGD,oCACC,gBAQF,8BACC,gBAMH,KACC,mBACA,mBAGD,SACC,aAGD,mBACC,mBAGD,eACC,gBAOA,+IACC,sBAEA,+KACC,aAGD,mKACC,WACA,YACA,kCACA,qBACA,kBAGD,mOACC,sCAGD,mNACC,sCAGD,mNACC,oCAMF,sBACC,aAGD,YACC,oBAGD,kBACC,kBAGD,yBACC,kBAGD,sBACC,kBAIF,yCACC,kBAGD,wBACC,qBAGD,2BACC,wBAEA,gBACA,aACA,iBACA,sBAKD,WACC,kBACA,2BACA,WAGD,2DAGC,qBAIA,mCACC,qBACA,YACA,iBAGD,+EAEC,YAIF,eACC,WAGD,SACC,iBAGD,QACC,qBACA,YACA,WACA,2BAEA,gBACC,kBAIF,qBACC,sBACA,qBACA,YACA,iBAGD,kBACC,qBACA,gBAIA,aACC,sCACA,mCAGD,WACC,oCAGD,mBACC,sCACA,oBAMF,8CACC,WACA,YAGD,wBACC,WACA,YACA,mBACA,kBACA,+DAIA,oBACC,iBACA,gBAEA,uBACC,cAGD,uBACC,kBAIF,0BACC,YACA,gCAGD,oDACC,yBAGD,wDACC,2BAGD,uBACC,cAKD,oBACC,0BAGD,oCACC,gBAIF,eACC,mBAEA,iBACC,qBACA,cAIF,SACC,UAGD,eACC,iBACA,mBACA,WASA,2CACC,aACA,qBACA,yCAEA,gDAGC,aACA,aACA,WAbgB,KAchB,sBACA,aACA,mBAGA,sBACE,+KASF,4CAEA,yDACC,WAID,iaAOC,UA1CkB,MA4ClB,ooCACC,6BACA,wBACA,uBAKD,odAMC,gBAMD,gPAGC,cACA,gBAIF,qSAKC,UA7EkB,MA+ElB,6UACC,WACA,6BACA,wBAGD,kVACC,cAIF,4DACC,YACA,WAGD,6DACC,aACA,yBACA,gBACA,UACA,eACA,8CAGD,2EACC,WAGD,0DACC,oCACA,wBAID,4DACC,gBACA,kBACA,8CACA,YACA,MAEA,mEACC,mDAIF,4DACC,oCACA,yBAEA,2wBAWC,iBACA,oBACA,oCACA,wBAKD,wEACC,sDAIF,qDACC,WAGD,2KAGC,WACA,oBACA,gCACA,YAEA,wQACC,2BACA,eAIA,qfACC,yBAKF,4SACC,WACA,YAGD,0LACC,qBAKA,kcACC,uBACA,YAIF,yYAGC,kBACA,gBACA,gBAIA,uBACA,oBACA,qBACA,4BAGD,wNACC,6BAGD,6LACC,aACA,qBACA,mBACA,kBAEA,wNACC,YACA,gBACA,WAIF,qNACC,cACA,eAEA,4PACC,aAIF,gMACC,YACA,WACA,WAEA,4MACC,cAIF,+MACC,aACA,mBACA,yBAGA,YACA,kBACA,oBACA,8CAKH,uEACC,aACA,mBACA,uBACA,sBAGD,2DACC,WACA,iBAKH,UACI,+CAGJ,2BACE,GACE,YAGJ,mCACE,GACE","file":"settings.css"}
\ No newline at end of file +{"version":3,"sourceRoot":"","sources":["settings.scss","../../../core/css/functions.scss"],"names":[],"mappings":"AAOC,0BACC,WAKF,OACC,WAID,4BC+CC,2CD3CD,mBC2CC,kDDvCD,qBCuCC,yCDnCD,0BCmCC,wCD/BD,oEC+BC,2CD3BD,oCACC,oBACA,0BACA,+BACA,mBAGD,4BACC,oBACA,kCAGD,yBACC,WAIA,wCACC,kBACA,yDACC,gBAIA,mOACC,WAKH,uCACC,aAGD,sCACC,WAED,uDACC,WAKD,gBACC,WAIF,mBACC,aACA,aACA,iBACA,4DACA,qBAEA,4BACC,kBACA,SAEA,+BACC,mBAIA,qCACC,iBAKH,kCACC,iBACA,mBACA,gBAGD,mGACC,4BACA,kBACA,WAMF,oBACC,kBACA,wCACC,0BACA,8CACC,oDAKH,aACC,qBACA,YACA,kBACA,8CACA,WACA,wCACA,8CACA,6CAEA,0DAGC,mCACA,mDACA,qDAGD,uBACC,kBACA,yBAIF,6BACC,oBACA,kCAEA,mCACC,WAIA,oCACC,kBACA,oBACA,iBACA,2BACA,WACA,mBACA,QAEA,0CACC,mBACA,uBACA,gBAKD,gIACC,kBACA,UACA,UACA,oBACA,YAKH,qCACC,kBACA,UACA,MACA,SAEA,yCACC,qBAIF,4CACC,eAGD,4CACC,sBACA,WACA,YACA,YAMF,qBACC,aACA,WACA,SACA,YAEA,uBACC,aAGD,uCACC,sBACA,cACA,yBAIF,iBACC,kBACA,eACA,WACA,YACA,aACA,SACA,gBACA,YAEA,8CAEC,+CACA,wCAEA,0FACC,WAIF,uCACC,kBACA,qBACA,gCACA,WACA,eAEA,wDACC,qBACA,sBACA,eAIF,sCACC,SAGC,4DAEC,iBACA,kBAEA,kFACC,YAGD,mEACC,oDAEA,kFACC,iBAIF,qEACC,WAEA,eAEA,uEACC,eAQN,gBACC,YAIA,2BACC,kCAGD,mBACC,YAIF,sCAEC,aAGD,eACC,WAGD,YACC,qBAIA,aACC,WACA,yBACA,YAGD,WACC,WACA,yBACA,YAMD,oBACC,iBAGD,iBACC,eAKD,iCACC,aACA,eACA,sBACA,SACA,gDACC,aACA,eACA,sBACA,sDACC,oBAIF,kGACC,cACA,YACA,gBAKA,iEACC,kBACA,UAED,+EACC,oBACA,eACA,wBACA,UAIF,wCACC,WAGD,iDACC,qBAGD,sDACC,kBACA,OACA,WACA,0BACA,eACA,gBACA,WAQF,oBACC,gBAGD,wBACC,iBAGD,oDACC,WACA,YACA,mBACA,wCAOD,oBACC,UACA,cACA,gBACA,uBAGD,2BACC,UAKD,oCAEC,cAKD,wEAEC,aAIF,gBACC,kBACA,QACA,QAEA,sBACC,YAGD,sBACC,iBAKF,WACC,WAEA,cACC,WACA,WACA,4CACA,eACA,kBACA,gBACA,mBAGD,cACC,4CACA,eACA,kBACA,gBACA,mBAKD,gBACC,kBACA,cACA,eACA,uBACA,gBAGD,wBACC,kBAEA,gCACC,kBAIF,sCACC,kBAGD,sDAEC,cACA,eACA,eAEA,0EACC,UACA,qBACA,uBACA,gBAIF,8BACC,eAGD,kCACC,mBACA,cAIF,2BACC,mBAID,4BACC,WACA,SACA,QAGD,0BACC,mBAGD,SACC,gBAKA,oBACC,mBACA,iBACA,WAGD,gCACC,kBAIA,gGACC,cAIF,4BACC,gBAGD,iCACC,gBAGD,8BACC,oCAIF,aACC,gBACA,iBACA,oCAGD,aACC,oCAIA,gBACC,oCACA,+BACA,+CACA,mCACA,gBAGD,aACC,aACA,YACA,mBAGD,qBACC,gCACA,+BACA,kBAGD,sBACC,kCACA,gCACA,+BACA,kBACA,2BAIF,WACC,kBACA,QACA,WAIA,qCACC,aAMD,0BACC,SAGD,2BACC,cACA,aAGD,yDACC,eAGD,8BACC,WAGD,qDACC,WACA,aACA,qBACA,WAGD,kCACC,YAGD,0BACC,gBAMA,4FAEC,qBACA,WACA,YACA,kBACA,WAIF,0CACC,YACA,WAEA,yGAEC,mBAGD,2DACC,YAIF,uFACC,oCAGD,iDACC,cAGD,kDACC,cAGD,sCACC,kBACA,MACA,QACA,aACA,WACA,UACA,WACA,YAGD,wCACC,aACA,mBAEA,oDACC,YAIF,yCACC,0BACA,iBAGD,iOAKC,cAIF,2CACC,gBACC,UAED,kCACC,WAIF,2CACC,gBACC,UAED,kCACC,WAIF,2CACC,gBACC,UAED,kCACC,WAIF,0CACC,gBACC,UAED,kCACC,YAIF,2CACC,gBACC,WAIF,0CACC,gBACC,YAKF,0CAEE,kEACC,yBAKH,0CACC,iCACC,yBAIF,SACC,gBAEA,0BACC,4CAID,YACC,mBAEA,uBACC,iBACA,2BACA,qBAKH,iBACC,cACA,yBAGD,WACC,kBACA,aACA,UACA,gBAGD,6CACC,qBAGD,0DACC,WACA,cACA,eACA,WAGD,2BACC,WACA,kBACA,QAGD,iBACC,WAGD,gBACC,mBAKD,kBACC,0BAGD,kBACC,cAGD,sBACC,mBACA,wBACA,2BAGD,WAyGC,aACA,eACA,yBAvGA,oBACC,eAGD,0BACC,wBAGD,gCACC,iBAGD,oBACC,OAfgB,KAgBhB,QAjBiB,IAmBjB,aAlBgB,KAmBhB,WACA,8CACA,gBACA,MACA,UACA,aACA,mBAGD,qBAQC,oBAPA,0CACC,cACA,WACA,YACA,WAjCe,KAsChB,8BACC,kBACA,UACA,SAEA,gCACC,mBACA,eACA,sBACA,WACA,4CACA,YACA,sBAGD,uCACC,8CAKF,oCACC,aAEA,0CACC,iBAIF,gCACC,WACA,YACA,iBAGD,kGAEC,eACA,WACA,YACA,WACA,sBACA,qBAGD,8BACC,iBAEA,kDACC,qBACA,QACA,kBAKH,+CACC,kBACA,YAEA,WACA,YACA,WAOD,kBACC,aAGD,oBACC,kBACA,cAEA,gCACC,cACA,aAGD,0BACC,8CAKD,8BACC,cAGD,+BACC,gBAGD,+BACC,mBAEA,oEACC,kBAKD,8DACC,iBAKD,oEACC,kBAMH,wBACC,kBACA,kBAEA,4BACC,mBACA,YAGD,2BACC,mBACA,kBACA,iBACA,iBAEA,mCACC,kBACA,SACA,iBAGD,oCACC,gBAQF,8BACC,gBAMH,KACC,mBACA,mBAGD,SACC,aAGD,mBACC,mBAGD,eACC,gBAOA,+IACC,sBAEA,+KACC,aAGD,mKACC,WACA,YACA,kCACA,qBACA,kBAGD,mOACC,sCAGD,mNACC,sCAGD,mNACC,oCAMF,sBACC,aAGD,YACC,oBAGD,kBACC,kBAGD,yBACC,kBAGD,sBACC,kBAIF,yCACC,kBAGD,wBACC,qBAGD,2BACC,wBAEA,gBACA,aACA,iBACA,sBAKD,WACC,kBACA,2BACA,WAGD,2DAGC,qBAIA,mCACC,qBACA,YACA,iBAGD,+EAEC,YAIF,eACC,WAGD,SACC,iBAGD,QACC,qBACA,YACA,WACA,2BAEA,gBACC,kBAIF,qBACC,sBACA,qBACA,YACA,iBAGD,kBACC,qBACA,gBAIA,aACC,sCACA,mCAGD,WACC,oCAGD,mBACC,sCACA,oBAMF,8CACC,WACA,YAGD,wBACC,WACA,YACA,mBACA,kBACA,+DAIA,oBACC,iBACA,gBAEA,uBACC,cAGD,uBACC,kBAIF,0BACC,YACA,gCAGD,oDACC,yBAGD,wDACC,2BAGD,uBACC,cAKD,oBACC,0BAGD,oCACC,gBAIF,eACC,mBAEA,iBACC,qBACA,cAIF,SACC,UAGD,eACC,iBACA,mBACA,WASA,2CACC,aACA,qBACA,yCAEA,gDAGC,aACA,aACA,WAbgB,KAchB,sBACA,aACA,mBAGA,sBACE,+KASF,4CAEA,yDACC,WAID,iaAOC,UA1CkB,MA4ClB,ooCACC,6BACA,wBACA,uBAKD,odAMC,gBAMD,gPAGC,cACA,gBAIF,qSAKC,UA7EkB,MA+ElB,6UACC,WACA,6BACA,wBAGD,kVACC,cAIF,4DACC,YACA,WAGD,6DACC,aACA,yBACA,gBACA,UACA,eACA,8CAGD,2EACC,WAGD,0DACC,oCACA,wBAMD,4DACC,gBACA,kBACA,8CACA,YACA,MAEA,mEACC,mDAIF,4DACC,oCACA,yBAEA,2wBAWC,iBACA,oBACA,oCACA,wBAKD,wEACC,sDAIF,qDACC,WAGD,2KAGC,WACA,oBACA,gCACA,YAEA,wQACC,2BACA,eAIA,qfACC,yBAKF,4SACC,WACA,YAGD,0LACC,qBAKA,kcACC,uBACA,YAIF,yYAGC,kBACA,gBACA,gBAIA,uBACA,oBACA,qBACA,4BAGD,wNACC,6BAGD,6LACC,aACA,qBACA,mBACA,kBAEA,wNACC,YACA,gBACA,WAIF,qNACC,cACA,eAEA,4PACC,aAIF,gMACC,YACA,WACA,WAEA,4MACC,cAIF,+MACC,aACA,mBACA,yBAGA,YACA,kBACA,oBACA,8CAKH,uEACC,aACA,mBACA,uBACA,sBAGD,2DACC,WACA,iBAKH,UACI,+CAGJ,2BACE,GACE,YAGJ,mCACE,GACE","file":"settings.css"}
\ No newline at end of file diff --git a/apps/settings/css/settings.scss b/apps/settings/css/settings.scss index e80c20d39fc..d48a9576c15 100644 --- a/apps/settings/css/settings.scss +++ b/apps/settings/css/settings.scss @@ -1317,284 +1317,6 @@ doesnotexist:-o-prefocus, .strengthify-wrapper { opacity: .7; } - -/* USERS LIST -------------------------------------------------------------- */ -#body-settings { - $grid-row-height: 60px; - $grid-col-min-width: 160px; - - #app-content.user-list-grid { - display: grid; - grid-column-gap: 20px; - grid-auto-rows: minmax(60px, max-content); - - .row { - // TODO replace with css4 subgrid when available - // fallback for ie11 no grid - display: flex; - display: grid; - min-height: $grid-row-height; - grid-row-start: span 1; - grid-gap: 3px; - align-items: center; - /* let's define the column until storage path, - what follows will be manually defined */ - grid-template-columns: - 44px - minmax($grid-col-min-width + 30px, 1fr) // username, displayname - minmax($grid-col-min-width, 1fr) // password - minmax($grid-col-min-width, 1fr) // email - minmax(1.5*$grid-col-min-width, 1fr) // groups - minmax(1.5*$grid-col-min-width, 1fr) // group admins - minmax($grid-col-min-width, 1fr) // quota - minmax(1.5*$grid-col-min-width, 1fr) // manager - repeat(auto-fit, minmax($grid-col-min-width, 1fr)); - border-bottom: var(--color-border) 1px solid; - - &.disabled { - opacity: .5; - } - - /* grid col width */ - .name, - .password, - .mailAddress, - .languages, - .storageLocation, - .userBackend, - .lastLogin { - min-width: $grid-col-min-width; - - doesnotexist:-o-prefocus, .strengthify-wrapper { - color: var(--color-text-dark); - vertical-align: baseline; - text-overflow: ellipsis; - } - } - - &:not(.row--editable) { - &.name, - &.password, - &.displayName, - &.mailAddress, - &.userBackend, - &.languages { - overflow: hidden; - } - } - - // Scroll if too much groups - &:not(.row--editable) { - .groups, - .subadmins, - .subAdminsGroups { - overflow: auto; - max-height: 100%; - } - } - - .managers, - .groups, - .subadmins, - .subAdminsGroups, - .quota { - min-width: $grid-col-min-width; - - .select { - width: 100%; - color: var(--color-text-dark); - vertical-align: baseline; - } - - progress { - max-width: 95%; - } - } - - .obfuscated { - width: 400px; - opacity: .7; - } - - .userActions { - display: flex; - justify-content: flex-end; - position: sticky; - right: 0px; - min-width: 88px; - background-color: var(--color-main-background); - } - - &.row--editable .userActions { - z-index: 10; - } - - .subtitle { - color: var(--color-text-maxcontrast); - vertical-align: baseline; - } - - /* various */ - &#grid-header { - position: sticky; - align-self: normal; - background-color: var(--color-main-background); - z-index: 100; /* above multiselect */ - top: 0; - - &.sticky { - box-shadow: 0 -2px 10px 1px var(--color-box-shadow); - } - } - - &#grid-header { - color: var(--color-text-maxcontrast); - border-bottom-width: thin; - - #headerDisplayName, - #headerPassword, - #headerAddress, - #headerGroups, - #headerSubAdmins, - #theHeaderUserBackend, - #theHeaderLastLogin, - #headerQuota, - #theHeaderStorageLocation, - #headerLanguages { - /* Line up header text with column content for when there’s inputs */ - padding-left: 7px; - text-transform: none; - color: var(--color-text-maxcontrast); - vertical-align: baseline; - } - } - - &:hover { - &:not(#grid-header) { - box-shadow: 5px 0 0 var(--color-primary-element) inset; - } - } - - > form { - width: 100%; - } - - > div, - > .displayName > form, - > form { - grid-row: 1; - display: inline-flex; - color: var(--color-text-lighter); - flex-grow: 1; - - > input:not(:focus):not(:active) { - border-color: transparent; - cursor: pointer; - } - - > input:focus, > input:active { - + .icon-confirm { - display: block !important; - } - } - - /* inputs like mail, username, password */ - &:not(.userActions) > input:not([type='submit']) { - width: 100%; - min-width: 0; - } - - &.name { - word-break: break-all; - } - - &.displayName, - &.mailAddress { - > input { - text-overflow: ellipsis; - flex-grow: 1; - } - } - - &.name, - &.userBackend { - /* better multi-line visual */ - line-height: 1.3em; - max-height: 100%; - overflow: hidden; - /* not supported by all browsers - so we keep the overflow hidden - as a fallback */ - text-overflow: ellipsis; - display: -webkit-box; - -webkit-line-clamp: 2; - -webkit-box-orient: vertical; - } - - &.name .subtitle { - color: var(--color-main-text); - } - - &.quota { - display: flex;; - justify-content: left; - white-space: nowrap; - position: relative; - - progress { - width: 150px; - margin-top: 35px; - height: 3px; - } - } - - .icon-confirm { - flex: 0 0 auto; - cursor: pointer; - - &:not(:active) { - display: none; - } - } - - &.avatar { - height: 32px; - width: 32px; - margin: 6px; - - img { - display: block; - } - } - - &.userActions { - display: flex; - align-items: center; - justify-content: flex-end; - - // Make sure to cover whole row - height: 100%; - width: fit-content; - padding-inline: 12px; - background-color: var(--color-main-background); - } - } - } - - .infinite-loading-container { - display: flex; - align-items: center; - justify-content: center; - grid-row-start: span 4; - } - - .users-list-end { - opacity: .5; - user-select: none; - } - } -} - .animated { animation: blink-animation 1s steps(5, start) 4; } diff --git a/apps/settings/src/components/UserList.vue b/apps/settings/src/components/UserList.vue index 2f3da92ca02..3d061a2f0d0 100644 --- a/apps/settings/src/components/UserList.vue +++ b/apps/settings/src/components/UserList.vue @@ -21,120 +21,90 @@ --> <template> - <div id="app-content" - role="grid" - :aria-label="t('settings', 'User\'s table')" - class="user-list-grid" - @scroll.passive="onScroll"> + <Fragment> <NewUserModal v-if="showConfig.showNewUserForm" :loading="loading" :new-user="newUser" - :show-config="showConfig" - @reset="resetForm" - @close="showConfig.showNewUserForm = false" /> - <div id="grid-header" - :class="{'sticky': scrolled && !showConfig.showNewUserForm}" - class="row"> - <div id="headerAvatar" class="avatar" /> - <div id="headerName" class="name"> - <div class="subtitle"> - <strong> - {{ t('settings', 'Display name') }} - </strong> - </div> - {{ t('settings', 'Username') }} - </div> - <div id="headerPassword" class="password"> - {{ t('settings', 'Password') }} - </div> - <div id="headerAddress" class="mailAddress"> - {{ t('settings', 'Email') }} - </div> - <div id="headerGroups" class="groups"> - {{ t('settings', 'Groups') }} - </div> - <div v-if="subAdminsGroups.length>0 && settings.isAdmin" - id="headerSubAdmins" - class="subadmins"> - {{ t('settings', 'Group admin for') }} - </div> - <div id="headerQuota" class="quota"> - {{ t('settings', 'Quota') }} - </div> - <div v-if="showConfig.showLanguages" - id="headerLanguages" - class="languages"> - {{ t('settings', 'Language') }} - </div> - - <div v-if="showConfig.showUserBackend || showConfig.showStoragePath" - class="headerUserBackend userBackend"> - <div v-if="showConfig.showUserBackend" class="userBackend"> - {{ t('settings', 'User backend') }} - </div> - <div v-if="showConfig.showStoragePath" - class="subtitle storageLocation"> - {{ t('settings', 'Storage location') }} - </div> - </div> - <div v-if="showConfig.showLastLogin" - class="headerLastLogin lastLogin"> - {{ t('settings', 'Last login') }} - </div> - <div id="headerManager" class="manager"> - {{ t('settings', 'Manager') }} - </div> - <div class="userActions" /> - </div> - - <UserRow v-for="user in filteredUsers" - :key="user.id" - :external-actions="externalActions" - :groups="groups" - :languages="languages" :quota-options="quotaOptions" - :settings="settings" - :show-config="showConfig" - :sub-admins-groups="subAdminsGroups" - :user="user" - :users="users" - :is-dark-theme="isDarkTheme" /> - - <InfiniteLoading ref="infiniteLoading" @infinite="infiniteHandler"> - <div slot="spinner"> - <div class="users-icon-loading icon-loading" /> - </div> - <div slot="no-more"> - <div class="users-list-end" /> - </div> - <div slot="no-results"> - <div id="emptycontent"> - <div class="icon-contacts-dark" /> - <h2>{{ t('settings', 'No users in here') }}</h2> - </div> - </div> - </InfiniteLoading> - </div> + @reset="resetForm" + @close="closeModal" /> + + <NcEmptyContent v-if="filteredUsers.length === 0" + class="empty" + :title="isInitialLoad && loading.users ? null : t('settings', 'No users')"> + <template #icon> + <NcLoadingIcon v-if="isInitialLoad && loading.users" + :title="t('settings', 'Loading users …')" + :size="64" /> + <NcIconSvgWrapper v-else + :svg="usersSvg" /> + </template> + </NcEmptyContent> + + <RecycleScroller v-else + class="user-list" + :style="style" + ref="scroller" + :items="filteredUsers" + key-field="id" + role="table" + list-tag="tbody" + list-class="user-list__body" + item-tag="tr" + item-class="user-list__row" + :item-size="rowHeight" + @hook:mounted="handleMounted" + @scroll-end="handleScrollEnd"> + + <template #before> + <caption class="hidden-visually"> + {{ t('settings', 'List of users. This list is not fully rendered for performances reasons. The users will be rendered as you navigate through the list.') }} + </caption> + <UserListHeader :has-obfuscated="hasObfuscated" /> + </template> + + <template #default="{ item: user }"> + <UserRow :user="user" + :users="users" + :settings="settings" + :has-obfuscated="hasObfuscated" + :groups="groups" + :sub-admins-groups="subAdminsGroups" + :quota-options="quotaOptions" + :languages="languages" + :external-actions="externalActions" /> + </template> + + <template #after> + <UserListFooter :loading="loading.users" + :filtered-users="filteredUsers" /> + </template> + + </RecycleScroller> + </Fragment> </template> <script> import Vue from 'vue' -import InfiniteLoading from 'vue-infinite-loading' +import { Fragment } from 'vue-frag' +import { RecycleScroller } from 'vue-virtual-scroller' + +import NcEmptyContent from '@nextcloud/vue/dist/Components/NcEmptyContent.js' +import NcIconSvgWrapper from '@nextcloud/vue/dist/Components/NcIconSvgWrapper.js' +import NcLoadingIcon from '@nextcloud/vue/dist/Components/NcLoadingIcon.js' import { subscribe, unsubscribe } from '@nextcloud/event-bus' +import { showError } from '@nextcloud/dialogs' -import UserRow from './Users/UserRow.vue' import NewUserModal from './Users/NewUserModal.vue' +import UserListFooter from './Users/UserListFooter.vue' +import UserListHeader from './Users/UserListHeader.vue' +import UserRow from './Users/UserRow.vue' -const unlimitedQuota = { - id: 'none', - label: t('settings', 'Unlimited'), -} +import { defaultQuota, isObfuscated, unlimitedQuota } from '../utils/userUtils.ts' +import logger from '../logger.js' -const defaultQuota = { - id: 'default', - label: t('settings', 'Default quota'), -} +import usersSvg from '../../img/users.svg?raw' const newUser = { id: '', @@ -155,20 +125,18 @@ export default { name: 'UserList', components: { - InfiniteLoading, + Fragment, + NcEmptyContent, + NcIconSvgWrapper, + NcLoadingIcon, NewUserModal, + RecycleScroller, + UserListFooter, + UserListHeader, UserRow, }, props: { - users: { - type: Array, - default: () => [], - }, - showConfig: { - type: Object, - required: true, - }, selectedGroup: { type: String, default: null, @@ -184,20 +152,39 @@ export default { loading: { all: false, groups: false, + users: false, }, - scrolled: false, + isInitialLoad: true, + rowHeight: 55, + usersSvg, searchQuery: '', newUser: Object.assign({}, newUser), } }, computed: { + showConfig() { + return this.$store.getters.getShowConfig + }, + settings() { return this.$store.getters.getServerData }, - selectedGroupDecoded() { - return decodeURIComponent(this.selectedGroup) + + style() { + return { + '--row-height': `${this.rowHeight}px`, + } + }, + + hasObfuscated() { + return this.filteredUsers.some(user => isObfuscated(user)) }, + + users() { + return this.$store.getters.getUsers + }, + filteredUsers() { if (this.selectedGroup === 'disabled') { return this.users.filter(user => user.enabled === false) @@ -208,16 +195,19 @@ export default { } return this.users.filter(user => user.enabled !== false) }, + groups() { // data provided php side + remove the disabled group return this.$store.getters.getGroups .filter(group => group.id !== 'disabled') .sort((a, b) => a.name.localeCompare(b.name)) }, + subAdminsGroups() { // data provided php side return this.$store.getters.getSubadminGroups }, + quotaOptions() { // convert the preset array into objects const quotaPreset = this.settings.quotaPreset.reduce((acc, cur) => acc.concat({ @@ -231,12 +221,15 @@ export default { quotaPreset.unshift(defaultQuota) return quotaPreset }, + usersOffset() { return this.$store.getters.getUsersOffset }, + usersLimit() { return this.$store.getters.getUsersLimit }, + usersCount() { return this.users.length }, @@ -254,37 +247,29 @@ export default { }, ] }, - isDarkTheme() { - return window.getComputedStyle(this.$el) - .getPropertyValue('--background-invert-if-dark') === 'invert(100%)' - }, }, + watch: { // watch url change and group select - selectedGroup(val, old) { + async selectedGroup(val, old) { + this.isInitialLoad = true // if selected is the disabled group but it's empty - this.redirectIfDisabled() + await this.redirectIfDisabled() this.$store.commit('resetUsers') - this.$refs.infiniteLoading.stateChanger.reset() + await this.loadUsers() this.setNewUserDefaultGroup(val) }, - // make sure the infiniteLoading state is changed if we manually - // add/remove data from the store - usersCount(val, old) { - // deleting the last user, reset the list - if (val === 0 && old === 1) { - this.$refs.infiniteLoading.stateChanger.reset() - // adding the first user, warn the infiniteLoader that - // the list is not empty anymore (we don't fetch the newly - // added user as we already have all the info we need) - } else if (val === 1 && old === 0) { - this.$refs.infiniteLoading.stateChanger.loaded() - } + filteredUsers(filteredUsers) { + logger.debug(`${filteredUsers.length} filtered user(s)`) }, }, - mounted() { + async created() { + await this.loadUsers() + }, + + async mounted() { if (!this.settings.canChangePassword) { OC.Notification.showTemporary(t('settings', 'Password change is disabled because the master key is disabled')) } @@ -303,40 +288,58 @@ export default { /** * If disabled group but empty, redirect */ - this.redirectIfDisabled() + await this.redirectIfDisabled() }, + beforeDestroy() { unsubscribe('nextcloud:unified-search.search', this.search) unsubscribe('nextcloud:unified-search.reset', this.resetSearch) }, methods: { - onScroll(event) { - this.scrolled = event.target.scrollTo > 0 + async handleMounted() { + // Add proper semantics to the recycle scroller slots + const header = this.$refs.scroller.$refs.before + const footer = this.$refs.scroller.$refs.after + header.classList.add('user-list__header') + header.setAttribute('role', 'rowgroup') + footer.classList.add('user-list__footer') + footer.setAttribute('role', 'rowgroup') }, - infiniteHandler($state) { - this.$store.dispatch('getUsers', { - offset: this.usersOffset, - limit: this.usersLimit, - group: this.selectedGroup !== 'disabled' ? this.selectedGroup : '', - search: this.searchQuery, - }) - .then((usersCount) => { - if (usersCount > 0) { - $state.loaded() - } - if (usersCount < this.usersLimit) { - $state.complete() - } + async handleScrollEnd() { + await this.loadUsers() + }, + + async loadUsers() { + this.loading.users = true + try { + await this.$store.dispatch('getUsers', { + offset: this.usersOffset, + limit: this.usersLimit, + group: this.selectedGroup !== 'disabled' ? this.selectedGroup : '', + search: this.searchQuery, }) + logger.debug(`${this.users.length} total user(s) loaded`) + } catch (error) { + logger.error('Failed to load users', { error }) + showError('Failed to load users') + } + this.loading.users = false + this.isInitialLoad = false + }, + + closeModal() { + this.$store.commit('setShowConfig', { + key: 'showNewUserForm', + value: false, + }) }, - /* SEARCH */ - search({ query }) { + async search({ query }) { this.searchQuery = query this.$store.commit('resetUsers') - this.$refs.infiniteLoading.stateChanger.reset() + await this.loadUsers() }, resetSearch() { @@ -384,15 +387,86 @@ export default { * we only check for 0 because we don't have the count on ldap * and we therefore set the usercount to -1 in this specific case */ - redirectIfDisabled() { + async redirectIfDisabled() { const allGroups = this.$store.getters.getGroups if (this.selectedGroup === 'disabled' && allGroups.findIndex(group => group.id === 'disabled' && group.usercount === 0) > -1) { // disabled group is empty, redirection to all users this.$router.push({ name: 'users' }) - this.$refs.infiniteLoading.stateChanger.reset() + await this.loadUsers() } }, }, } </script> + +<style lang="scss" scoped> +@import './Users/shared/styles.scss'; + +.empty { + :deep { + .icon-vue { + width: 64px; + height: 64px; + + svg { + max-width: 64px; + max-height: 64px; + } + } + } +} + +.user-list { + --avatar-cell-width: 48px; + --cell-padding: 7px; + --cell-width: 200px; + --cell-min-width: calc(var(--cell-width) - (2 * var(--cell-padding))); + + display: block; + overflow: auto; + height: 100%; + + :deep { + .user-list { + &__body { + display: flex; + flex-direction: column; + width: 100%; + // Necessary for virtual scrolling absolute + position: relative; + margin-top: var(--row-height); + } + + &__row { + @include row; + border-bottom: 1px solid var(--color-border); + + &:hover { + background-color: var(--color-background-hover); + + .row__cell:not(.row__cell--actions) { + background-color: var(--color-background-hover); + } + } + } + } + + .vue-recycle-scroller__slot { + &.user-list__header, + &.user-list__footer { + position: sticky; + } + + &.user-list__header { + top: 0; + z-index: 10; + } + + &.user-list__footer { + left: 0; + } + } + } +} +</style> diff --git a/apps/settings/src/components/Users/NewUserModal.vue b/apps/settings/src/components/Users/NewUserModal.vue index be7714f9373..1ad4a6fafaa 100644 --- a/apps/settings/src/components/Users/NewUserModal.vue +++ b/apps/settings/src/components/Users/NewUserModal.vue @@ -182,16 +182,6 @@ import NcPasswordField from '@nextcloud/vue/dist/Components/NcPasswordField.js' import NcSelect from '@nextcloud/vue/dist/Components/NcSelect.js' import NcTextField from '@nextcloud/vue/dist/Components/NcTextField.js' -const unlimitedQuota = { - id: 'none', - label: t('settings', 'Unlimited'), -} - -const defaultQuota = { - id: 'default', - label: t('settings', 'Default quota'), -} - export default { name: 'NewUserModal', @@ -214,8 +204,8 @@ export default { required: true, }, - showConfig: { - type: Object, + quotaOptions: { + type: Array, required: true, }, }, @@ -227,6 +217,10 @@ export default { }, computed: { + showConfig() { + return this.$store.getters.getShowConfig + }, + settings() { return this.$store.getters.getServerData }, @@ -265,20 +259,6 @@ export default { }) }, - quotaOptions() { - // convert the preset array into objects - const quotaPreset = this.settings.quotaPreset.reduce((acc, cur) => acc.concat({ - id: cur, - label: cur, - }), []) - // add default presets - if (this.settings.allowUnlimitedQuota) { - quotaPreset.unshift(unlimitedQuota) - } - quotaPreset.unshift(defaultQuota) - return quotaPreset - }, - languages() { return [ { diff --git a/apps/settings/src/components/Users/UserListFooter.vue b/apps/settings/src/components/Users/UserListFooter.vue new file mode 100644 index 00000000000..c8713369203 --- /dev/null +++ b/apps/settings/src/components/Users/UserListFooter.vue @@ -0,0 +1,126 @@ +<!-- + - @copyright 2023 Christopher Ng <chrng8@gmail.com> + - + - @author Christopher Ng <chrng8@gmail.com> + - + - @license AGPL-3.0-or-later + - + - This program is free software: you can redistribute it and/or modify + - it under the terms of the GNU Affero General Public License as + - published by the Free Software Foundation, either version 3 of the + - License, or (at your option) any later version. + - + - This program is distributed in the hope that it will be useful, + - but WITHOUT ANY WARRANTY; without even the implied warranty of + - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + - GNU Affero General Public License for more details. + - + - You should have received a copy of the GNU Affero General Public License + - along with this program. If not, see <http://www.gnu.org/licenses/>. + - +--> + +<template> + <tr class="footer"> + <th scope="row"> + <span class="hidden-visually">{{ t('settings', 'Total rows summary') }}</span> + </th> + <td class="footer__cell footer__cell--loading"> + <NcLoadingIcon v-if="loading" + :title="t('settings', 'Loading users …')" + :size="32" /> + </td> + <td class="footer__cell footer__cell--count footer__cell--multiline"> + <span aria-describedby="user-count-desc">{{ userCount }}</span> + <span id="user-count-desc" + class="hidden-visually"> + {{ t('settings', 'Scroll to load more rows') }} + </span> + </td> + </tr> +</template> + +<script lang="ts"> +import Vue from 'vue' +import NcLoadingIcon from '@nextcloud/vue/dist/Components/NcLoadingIcon.js' + +import { + translate as t, + translatePlural as n, +} from '@nextcloud/l10n' + +export default Vue.extend({ + name: 'UserListFooter', + + components: { + NcLoadingIcon, + }, + + props: { + loading: { + type: Boolean, + required: true, + }, + filteredUsers: { + type: Array, + required: true, + }, + }, + + computed: { + userCount(): string { + if (this.loading) { + return this.n( + 'settings', + '{userCount} user …', + '{userCount} users …', + this.filteredUsers.length, + { + userCount: this.filteredUsers.length, + }, + ) + } + return this.n( + 'settings', + '{userCount} user', + '{userCount} users', + this.filteredUsers.length, + { + userCount: this.filteredUsers.length, + }, + ) + }, + }, + + methods: { + t, + n, + }, +}) +</script> + +<style lang="scss" scoped> +@import './shared/styles.scss'; + +.footer { + @include row; + @include cell; + + &__cell { + position: sticky; + color: var(--color-text-maxcontrast); + + &--loading { + left: 0; + width: var(--avatar-cell-width); + align-items: center; + padding: 0; + } + + &--count { + left: var(--avatar-cell-width); + width: var(--cell-width); + } + } +} +</style> diff --git a/apps/settings/src/components/Users/UserListHeader.vue b/apps/settings/src/components/Users/UserListHeader.vue new file mode 100644 index 00000000000..3357d1c1bc2 --- /dev/null +++ b/apps/settings/src/components/Users/UserListHeader.vue @@ -0,0 +1,150 @@ +<!-- + - @copyright 2023 Christopher Ng <chrng8@gmail.com> + - + - @author Christopher Ng <chrng8@gmail.com> + - + - @license AGPL-3.0-or-later + - + - This program is free software: you can redistribute it and/or modify + - it under the terms of the GNU Affero General Public License as + - published by the Free Software Foundation, either version 3 of the + - License, or (at your option) any later version. + - + - This program is distributed in the hope that it will be useful, + - but WITHOUT ANY WARRANTY; without even the implied warranty of + - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + - GNU Affero General Public License for more details. + - + - You should have received a copy of the GNU Affero General Public License + - along with this program. If not, see <http://www.gnu.org/licenses/>. + - +--> + +<template> + <tr class="header"> + <th class="header__cell header__cell--avatar" + scope="col"> + <span class="hidden-visually"> + {{ t('settings', 'Avatar') }} + </span> + </th> + <th class="header__cell header__cell--displayname" + scope="col"> + <strong> + {{ t('settings', 'Display name') }} + </strong> + <span class="header__subtitle"> + {{ t('settings', 'Username') }} + </span> + </th> + <th class="header__cell" + :class="{ 'header__cell--obfuscated': hasObfuscated }" + scope="col"> + <span>{{ passwordLabel }}</span> + </th> + <th class="header__cell" + scope="col"> + <span>{{ t('settings', 'Email') }}</span> + </th> + <th class="header__cell header__cell--large" + scope="col"> + <span>{{ t('settings', 'Groups') }}</span> + </th> + <th v-if="subAdminsGroups.length > 0 && settings.isAdmin" + class="header__cell header__cell--large" + scope="col"> + <span>{{ t('settings', 'Group admin for') }}</span> + </th> + <th class="header__cell" + scope="col"> + <span>{{ t('settings', 'Quota') }}</span> + </th> + <th v-if="showConfig.showLanguages" + class="header__cell header__cell--large" + scope="col"> + <span>{{ t('settings', 'Language') }}</span> + </th> + <th v-if="showConfig.showUserBackend || showConfig.showStoragePath" + class="header__cell header__cell--large" + scope="col"> + <span v-if="showConfig.showUserBackend"> + {{ t('settings', 'User backend') }} + </span> + <span v-if="showConfig.showStoragePath" + class="header__subtitle"> + {{ t('settings', 'Storage location') }} + </span> + </th> + <th v-if="showConfig.showLastLogin" + class="header__cell" + scope="col"> + <span>{{ t('settings', 'Last login') }}</span> + </th> + <th class="header__cell header__cell--large" + scope="col"> + <span>{{ t('settings', 'Manager') }}</span> + </th> + <th class="header__cell header__cell--actions" + scope="col"> + <span class="hidden-visually"> + {{ t('settings', 'User actions') }} + </span> + </th> + </tr> +</template> + +<script lang="ts"> +import Vue from 'vue' + +import { translate as t } from '@nextcloud/l10n' + +export default Vue.extend({ + name: 'UserListHeader', + + props: { + hasObfuscated: { + type: Boolean, + required: true, + }, + }, + + computed: { + showConfig() { + // @ts-expect-error: allow untyped $store + return this.$store.getters.getShowConfig + }, + + settings() { + // @ts-expect-error: allow untyped $store + return this.$store.getters.getServerData + }, + + subAdminsGroups() { + // @ts-expect-error: allow untyped $store + return this.$store.getters.getSubadminGroups + }, + + passwordLabel(): string { + if (this.hasObfuscated) { + return t('settings', 'Password or insufficient permissions message') + } + return t('settings', 'Password') + }, + }, + + methods: { + t, + }, +}) +</script> + +<style lang="scss" scoped> +@import './shared/styles.scss'; + +.header { + @include row; + @include cell; + + border-bottom: 1px solid var(--color-border); +} +</style> diff --git a/apps/settings/src/components/Users/UserRow.vue b/apps/settings/src/components/Users/UserRow.vue index 6d5850068de..1a4879ccf26 100644 --- a/apps/settings/src/components/Users/UserRow.vue +++ b/apps/settings/src/components/Users/UserRow.vue @@ -1,9 +1,10 @@ <!-- - - @copyright Copyright (c) 2018 John Molakvoæ <skjnldsv@protonmail.com> - @copyright Copyright (c) 2019 Gary Kim <gary@garykim.dev> + - @copyright Copyright (c) 2018 John Molakvoæ <skjnldsv@protonmail.com> - - - @author John Molakvoæ <skjnldsv@protonmail.com> + - @author Christopher Ng <chrng8@gmail.com> - @author Gary Kim <gary@garykim.dev> + - @author John Molakvoæ <skjnldsv@protonmail.com> - - @license GNU AGPL version 3 or any later version - @@ -23,289 +24,352 @@ --> <template> - <!-- Obfuscated user: Logged in user does not have permissions to see all of the data --> - <div v-if="Object.keys(user).length ===1" :data-id="user.id" class="row"> - <div :class="{'icon-loading-small': loading.delete || loading.disable || loading.wipe}" - class="avatar"> - <img v-if="!loading.delete && !loading.disable && !loading.wipe" - :src="generateAvatar(user.id, isDarkTheme)" - alt="" - height="32" - width="32"> - </div> - <div class="name"> - {{ user.id }} - </div> - <div class="obfuscated"> - {{ t('settings','You do not have permissions to see the details of this user') }} - </div> - </div> - - <!-- User full data --> - <UserRowSimple v-else-if="!editing" - :editing.sync="editing" - :groups="groups" - :languages="languages" - :loading="loading" - :opened-menu.sync="openedMenu" - :settings="settings" - :show-config="showConfig" - :sub-admins-groups="subAdminsGroups" - :user-actions="userActions" - :user="user" - :is-dark-theme="isDarkTheme" - :class="{'row--menu-opened': openedMenu}" /> - <div v-else - :class="{ - 'disabled': loading.delete || loading.disable, - 'row--menu-opened': openedMenu - }" - :data-id="user.id" - class="row row--editable"> - <div :class="{'icon-loading-small': loading.delete || loading.disable || loading.wipe}" - class="avatar"> - <img v-if="!loading.delete && !loading.disable && !loading.wipe" - :src="generateAvatar(user.id, isDarkTheme)" - alt="" - height="32" - width="32"> - </div> - <!-- dirty hack to ellipsis on two lines --> - <div v-if="user.backendCapabilities.setDisplayName" class="displayName"> - <label class="hidden-visually" :for="'displayName'+user.id+rand">{{ t('settings', 'Edit display name') }}</label> - <NcTextField :id="'displayName'+user.id+rand" - :show-trailing-button="true" - class="user-row-text-field" - :class="{'icon-loading-small': loading.displayName}" - :disabled="loading.displayName||loading.all" - trailing-button-icon="arrowRight" - :value.sync="editedDisplayName" - autocapitalize="off" - autocomplete="off" - autocorrect="off" - spellcheck="false" - type="text" - @trailing-button-click="updateDisplayName" /> - </div> - <div v-else class="name"> - {{ user.id }} - <div class="displayName subtitle"> - <div :title="user.displayname.length > 20 ? user.displayname : ''" class="cellText"> + <Fragment> + <td class="row__cell row__cell--avatar"> + <NcLoadingIcon v-if="isLoadingUser" + :title="t('settings', 'Loading user …')" + :size="32" /> + <NcAvatar v-else + :key="user.id" + disable-menu + :show-user-status="false" + :user="user.id" /> + </td> + + <td class="row__cell row__cell--displayname"> + <template v-if="idState.editing && user.backendCapabilities.setDisplayName"> + <label class="hidden-visually" + :for="'displayName' + uniqueId"> + {{ t('settings', 'Edit display name') }} + </label> + <NcTextField :id="'displayName' + uniqueId" + ref="displayNameField" + :show-trailing-button="true" + class="user-row-text-field" + :class="{ 'icon-loading-small': idState.loading.displayName }" + :disabled="idState.loading.displayName || isLoadingField" + trailing-button-icon="arrowRight" + :value.sync="idState.editedDisplayName" + autocapitalize="off" + autocomplete="off" + autocorrect="off" + spellcheck="false" + type="text" + @trailing-button-click="updateDisplayName" /> + </template> + <template v-else> + <strong v-if="!isObfuscated" + :title="user.displayname?.length > 20 ? user.displayname : null"> {{ user.displayname }} - </div> - </div> - </div> - <div v-if="settings.canChangePassword && user.backendCapabilities.setPassword" class="password"> - <label class="hidden-visually" :for="'password'+user.id+rand">{{ t('settings', 'Add new password') }}</label> - <NcTextField :id="'password'+user.id+rand" - :show-trailing-button="true" - class="user-row-text-field" - :class="{'icon-loading-small': loading.password}" - :disabled="loading.password || loading.all" - :minlength="minPasswordLength" - maxlength="469" - :placeholder="t('settings', 'Add new password')" - trailing-button-icon="arrowRight" - :value.sync="editedPassword" - autocapitalize="off" - autocomplete="new-password" - autocorrect="off" - required - spellcheck="false" - type="password" - @trailing-button-click="updatePassword" /> - </div> - - <div v-else /> - - <div class="mailAddress"> - <label class="hidden-visually" :for="'mailAddress'+user.id+rand">{{ t('settings', 'Add new email address') }}</label> - <NcTextField :id="'mailAddress'+user.id+rand" - :show-trailing-button="true" - class="user-row-text-field" - :class="{'icon-loading-small': loading.mailAddress}" - :disabled="loading.mailAddress||loading.all" - :placeholder="t('settings', 'Add new email address')" - trailing-button-icon="arrowRight" - :value.sync="editedMail" - autocapitalize="off" - autocomplete="new-password" - autocorrect="off" - spellcheck="false" - type="email" - @trailing-button-click="updateEmail" /> - </div> - <div :class="{'icon-loading-small': loading.groups}" class="groups"> - <label class="hidden-visually" :for="'groups'+user.id+rand">{{ t('settings', 'Add user to group') }}</label> - <NcSelect :input-id="'groups'+user.id+rand" - :close-on-select="false" - :disabled="loading.groups||loading.all" - :multiple="true" - :options="availableGroups" - :placeholder="t('settings', 'Add user to group')" - :taggable="settings.isAdmin" - :value="userGroups" - class="select-vue" - label="name" - :no-wrap="true" - :selectable="() => userGroups.length < 2" - :create-option="(value) => ({ name: value, isCreating: true })" - @option:created="createGroup" - @option:selected="options => addUserGroup(options.at(-1))" - @option:deselected="removeUserGroup" /> - </div> - <div v-if="subAdminsGroups.length>0 && settings.isAdmin" - :class="{'icon-loading-small': loading.subadmins}" - class="subadmins"> - <label class="hidden-visually" :for="'subadmins'+user.id+rand">{{ t('settings', 'Set user as admin for') }}</label> - <NcSelect :id="'subadmins'+user.id+rand" - :close-on-select="false" - :disabled="loading.subadmins||loading.all" - label="name" - :multiple="true" - :no-wrap="true" - :selectable="() => userSubAdminsGroups.length < 2" - :options="subAdminsGroups" - :placeholder="t('settings', 'Set user as admin for')" - :value="userSubAdminsGroups" - class="select-vue" - @option:deselected="removeUserSubAdmin" - @option:selected="options => addUserSubAdmin(options.at(-1))" /> - </div> - <div :title="usedSpace" - :class="{'icon-loading-small': loading.quota}" - class="quota"> - <label class="hidden-visually" :for="'quota'+user.id+rand">{{ t('settings', 'Select user quota') }}</label> - <NcSelect v-model="userQuota" - :close-on-select="true" - :create-option="validateQuota" - :disabled="loading.quota||loading.all" - :input-id="'quota'+user.id+rand" - class="select-vue" - :options="quotaOptions" - :placeholder="t('settings', 'Select user quota')" - :taggable="true" - @option:selected="setUserQuota" /> - </div> - <div v-if="showConfig.showLanguages" - :class="{'icon-loading-small': loading.languages}" - class="languages"> - <label class="hidden-visually" :for="'language'+user.id+rand">{{ t('settings', 'Set the language') }}</label> - <NcSelect :id="'language'+user.id+rand" - :allow-empty="false" - :disabled="loading.languages||loading.all" - :options="availableLanguages" - :placeholder="t('settings', 'No language set')" - :value="userLanguage" - label="name" - class="select-vue" - @input="setUserLanguage" /> - </div> - - <div v-if="showConfig.showStoragePath || showConfig.showUserBackend" - class="storageLocation" /> - <div v-if="showConfig.showLastLogin" /> - - <div :class="{'icon-loading-small': loading.manager}" class="managers"> - <label class="hidden-visually" :for="'manager'+user.id+rand">{{ t('settings', 'Set the language') }}</label> - <NcSelect v-model="currentManager" - :input-id="'manager'+user.id+rand" - :close-on-select="true" - label="displayname" - :options="possibleManagers" - :placeholder="t('settings', 'Select manager')" - class="select-vue" - @search="searchUserManager" - @option:selected="updateUserManager" - @input="updateUserManager" /> - </div> - - <div class="userActions"> - <UserRowActions v-if="!loading.all" + </strong> + <span class="row__subtitle">{{ user.id }}</span> + </template> + </td> + + <td class="row__cell" + :class="{ 'row__cell--obfuscated': hasObfuscated }"> + <template v-if="idState.editing && settings.canChangePassword && user.backendCapabilities.setPassword"> + <label class="hidden-visually" + :for="'password' + uniqueId"> + {{ t('settings', 'Add new password') }} + </label> + <NcTextField :id="'password' + uniqueId" + :show-trailing-button="true" + class="user-row-text-field" + :class="{'icon-loading-small': idState.loading.password}" + :disabled="idState.loading.password || isLoadingField" + :minlength="minPasswordLength" + maxlength="469" + :placeholder="t('settings', 'Add new password')" + trailing-button-icon="arrowRight" + :value.sync="idState.editedPassword" + autocapitalize="off" + autocomplete="new-password" + autocorrect="off" + required + spellcheck="false" + type="password" + @trailing-button-click="updatePassword" /> + </template> + <span v-else-if="isObfuscated"> + {{ t('settings', 'You do not have permissions to see the details of this user') }} + </span> + </td> + + <td class="row__cell"> + <template v-if="idState.editing"> + <label class="hidden-visually" + :for="'mailAddress' + uniqueId"> + {{ t('settings', 'Add new email address') }} + </label> + <NcTextField :id="'mailAddress' + uniqueId" + :show-trailing-button="true" + class="user-row-text-field" + :class="{'icon-loading-small': idState.loading.mailAddress}" + :disabled="idState.loading.mailAddress || isLoadingField" + :placeholder="t('settings', 'Add new email address')" + trailing-button-icon="arrowRight" + :value.sync="idState.editedMail" + autocapitalize="off" + autocomplete="new-password" + autocorrect="off" + spellcheck="false" + type="email" + @trailing-button-click="updateEmail" /> + </template> + <span v-else-if="!isObfuscated" + :title="user.email?.length > 20 ? user.email : null"> + {{ user.email }} + </span> + </td> + + <td class="row__cell row__cell--large row__cell--multiline"> + <template v-if="idState.editing"> + <label class="hidden-visually" + :for="'groups' + uniqueId"> + {{ t('settings', 'Add user to group') }} + </label> + <NcSelect :input-id="'groups' + uniqueId" + :close-on-select="false" + :disabled="idState.loading.groups || isLoadingField" + :loading="idState.loading.groups" + :multiple="true" + :options="availableGroups" + :placeholder="t('settings', 'Add user to group')" + :taggable="settings.isAdmin" + :value="userGroups" + class="select-vue" + label="name" + :no-wrap="true" + :create-option="(value) => ({ name: value, isCreating: true })" + @option:created="createGroup" + @option:selected="options => addUserGroup(options.at(-1))" + @option:deselected="removeUserGroup" /> + </template> + <span v-else-if="!isObfuscated" + :title="userGroupsLabels?.length > 40 ? userGroupsLabels : null"> + {{ userGroupsLabels }} + </span> + </td> + + <td v-if="subAdminsGroups.length > 0 && settings.isAdmin" + class="row__cell row__cell--large row__cell--multiline"> + <template v-if="idState.editing && settings.isAdmin && subAdminsGroups.length > 0"> + <label class="hidden-visually" + :for="'subadmins' + uniqueId"> + {{ t('settings', 'Set user as admin for') }} + </label> + <NcSelect :id="'subadmins' + uniqueId" + :close-on-select="false" + :disabled="idState.loading.subadmins || isLoadingField" + :loading="idState.loading.subadmins" + label="name" + :multiple="true" + :no-wrap="true" + :options="subAdminsGroups" + :placeholder="t('settings', 'Set user as admin for')" + :value="userSubAdminsGroups" + class="select-vue" + @option:deselected="removeUserSubAdmin" + @option:selected="options => addUserSubAdmin(options.at(-1))" /> + </template> + <span v-else-if="!isObfuscated" + :title="userSubAdminsGroupsLabels?.length > 40 ? userSubAdminsGroupsLabels : null"> + {{ userSubAdminsGroupsLabels }} + </span> + </td> + + <td class="row__cell"> + <template v-if="idState.editing"> + <label class="hidden-visually" + :for="'quota' + uniqueId"> + {{ t('settings', 'Select user quota') }} + </label> + <NcSelect v-model="editedUserQuota" + :close-on-select="true" + :create-option="validateQuota" + :disabled="idState.loading.quota || isLoadingField" + :loading="idState.loading.quota" + :clearable="false" + :input-id="'quota' + uniqueId" + class="select-vue" + :options="quotaOptions" + :placeholder="t('settings', 'Select user quota')" + :taggable="true" + @option:selected="setUserQuota" /> + </template> + <template v-else-if="!isObfuscated"> + <label :for="'quota-progress' + uniqueId">{{ userQuota }} ({{ usedSpace }})</label> + <NcProgressBar class="row__progress" + :id="'quota-progress' + uniqueId" + :class="{ + 'row__progress--warn': usedQuota > 80, + }" + :value="usedQuota" /> + </template> + </td> + + <td v-if="showConfig.showLanguages" + class="row__cell row__cell--large"> + <template v-if="idState.editing"> + <label class="hidden-visually" + :for="'language' + uniqueId"> + {{ t('settings', 'Set the language') }} + </label> + <NcSelect :id="'language' + uniqueId" + :allow-empty="false" + :disabled="idState.loading.languages || isLoadingField" + :loading="idState.loading.languages" + :clearable="false" + :options="availableLanguages" + :placeholder="t('settings', 'No language set')" + :value="userLanguage" + label="name" + class="select-vue" + @input="setUserLanguage" /> + </template> + <span v-else-if="!isObfuscated"> + {{ userLanguage.name }} + </span> + </td> + + <td v-if="showConfig.showUserBackend || showConfig.showStoragePath" + class="row__cell row__cell--large"> + <template v-if="!isObfuscated"> + <span v-if="showConfig.showUserBackend">{{ user.backend }}</span> + <span v-if="showConfig.showStoragePath" + :title="user.storageLocation" + class="row__subtitle"> + {{ user.storageLocation }} + </span> + </template> + </td> + + <td v-if="showConfig.showLastLogin" + :title="userLastLoginTooltip" + class="row__cell"> + <span v-if="!isObfuscated">{{ userLastLogin }}</span> + </td> + + <td class="row__cell row__cell--large"> + <template v-if="idState.editing"> + <label class="hidden-visually" + :for="'manager' + uniqueId"> + {{ t('settings', 'Set the manager') }} + </label> + <NcSelect v-model="idState.currentManager" + :input-id="'manager' + uniqueId" + :close-on-select="true" + :disabled="idState.loading.manager || isLoadingField" + :loading="idState.loading.manager" + label="displayname" + :options="idState.possibleManagers" + :placeholder="t('settings', 'Select manager')" + class="select-vue" + @search="searchUserManager" + @option:selected="updateUserManager" + @input="updateUserManager" /> + </template> + <span v-else-if="!isObfuscated"> + {{ user.manager }} + </span> + </td> + + <td class="row__cell row__cell--actions"> + <UserRowActions v-if="!isObfuscated && canEdit && !idState.loading.all" :actions="userActions" - :edit="true" + :disabled="isLoadingField" + :edit="idState.editing" @update:edit="toggleEdit" /> - </div> - </div> + </td> + </Fragment> </template> <script> +import { Fragment } from 'vue-frag' +import { IdState } from 'vue-virtual-scroller' +import { getCurrentUser } from '@nextcloud/auth' import { showSuccess, showError } from '@nextcloud/dialogs' +import NcAvatar from '@nextcloud/vue/dist/Components/NcAvatar.js' +import NcLoadingIcon from '@nextcloud/vue/dist/Components/NcLoadingIcon.js' +import NcProgressBar from '@nextcloud/vue/dist/Components/NcProgressBar.js' import NcSelect from '@nextcloud/vue/dist/Components/NcSelect.js' import NcTextField from '@nextcloud/vue/dist/Components/NcTextField.js' -import ClickOutside from 'vue-click-outside' import UserRowActions from './UserRowActions.vue' -import UserRowSimple from './UserRowSimple.vue' + import UserRowMixin from '../../mixins/UserRowMixin.js' +import { isObfuscated, unlimitedQuota } from '../../utils/userUtils.ts' export default { name: 'UserRow', components: { + Fragment, + NcAvatar, + NcLoadingIcon, + NcProgressBar, NcSelect, NcTextField, UserRowActions, - UserRowSimple, }, - directives: { - ClickOutside, - }, - - mixins: [UserRowMixin], + mixins: [ + /** + * Use scoped `idState` instead of `data` which is reused between rows + * + * See https://github.com/Akryum/vue-virtual-scroller/tree/v1/packages/vue-virtual-scroller#why-is-this-useful + */ + IdState({ + idProp: vm => vm.user.id, + }), + UserRowMixin, + ], props: { + user: { + type: Object, + required: true, + }, users: { type: Array, required: true, }, - user: { - type: Object, + hasObfuscated: { + type: Boolean, required: true, }, - settings: { - type: Object, - default: () => ({}), - }, groups: { type: Array, default: () => [], }, subAdminsGroups: { type: Array, - default: () => [], + required: true, }, quotaOptions: { type: Array, - default: () => [], - }, - showConfig: { - type: Object, - default: () => ({}), + required: true, }, languages: { type: Array, required: true, }, + settings: { + type: Object, + required: true, + }, externalActions: { type: Array, default: () => [], }, - isDarkTheme: { - type: Boolean, - required: true, - }, }, - data() { + + idState() { return { - // default quota is set to unlimited - unlimitedQuota: { id: 'none', label: t('settings', 'Unlimited') }, - // temporary value used for multiselect change selectedQuota: false, - rand: parseInt(Math.random() * 1000), - openedMenu: false, + rand: Math.random().toString(36).substring(2), possibleManagers: [], currentManager: '', editing: false, @@ -330,7 +394,69 @@ export default { }, computed: { - /* USER POPOVERMENU ACTIONS */ + isObfuscated() { + return isObfuscated(this.user) + }, + + showConfig() { + return this.$store.getters.getShowConfig + }, + + isLoadingUser() { + return this.idState.loading.delete || this.idState.loading.disable || this.idState.loading.wipe + }, + + isLoadingField() { + return this.idState.loading.delete || this.idState.loading.disable || this.idState.loading.all + }, + + uniqueId() { + return this.user.id + this.idState.rand + }, + + userGroupsLabels() { + return this.userGroups + .map(group => group.name) + .join(', ') + }, + + userSubAdminsGroupsLabels() { + return this.userSubAdminsGroups + .map(group => group.name) + .join(', ') + }, + + usedSpace() { + if (this.user.quota?.used) { + return t('settings', '{size} used', { size: OC.Util.humanFileSize(this.user.quota?.used) }) + } + return t('settings', '{size} used', { size: OC.Util.humanFileSize(0) }) + }, + + canEdit() { + return getCurrentUser().uid !== this.user.id || this.settings.isAdmin + }, + + userQuota() { + let quota = this.user.quota?.quota + + if (quota === 'default') { + quota = this.settings.defaultQuota + if (quota !== 'none') { + // convert to numeric value to match what the server would usually return + quota = OC.Util.computerFileSize(quota) + } + } + + // when the default quota is unlimited, the server returns -3 here, map it to "none" + if (quota === 'none' || quota === -3) { + return t('settings', 'Unlimited') + } else if (quota >= 0) { + return OC.Util.humanFileSize(quota) + } + return OC.Util.humanFileSize(0) + }, + userActions() { const actions = [ { @@ -360,19 +486,19 @@ export default { }, // mapping saved values to objects - userQuota: { + editedUserQuota: { get() { - if (this.selectedQuota !== false) { - return this.selectedQuota + if (this.idState.selectedQuota !== false) { + return this.idState.selectedQuota } - if (this.settings.defaultQuota !== this.unlimitedQuota.id && OC.Util.computerFileSize(this.settings.defaultQuota) >= 0) { + if (this.settings.defaultQuota !== unlimitedQuota.id && OC.Util.computerFileSize(this.settings.defaultQuota) >= 0) { // if value is valid, let's map the quotaOptions or return custom quota return { id: this.settings.defaultQuota, label: this.settings.defaultQuota } } - return this.unlimitedQuota // unlimited + return unlimitedQuota // unlimited }, set(quota) { - this.selectedQuota = quota + this.idState.selectedQuota = quota }, }, @@ -390,14 +516,6 @@ export default { }, methods: { - /* MENU HANDLING */ - toggleMenu() { - this.openedMenu = !this.openedMenu - }, - hideMenu() { - this.openedMenu = false - }, - wipeUserDevices() { const userid = this.user.id OC.dialogs.confirmDestructive( @@ -411,13 +529,13 @@ export default { }, (result) => { if (result) { - this.loading.wipe = true - this.loading.all = true + this.idState.loading.wipe = true + this.idState.loading.all = true this.$store.dispatch('wipeUserDevices', userid) .then(() => showSuccess(t('settings', 'Wiped {userid}\'s devices', { userid })), { timeout: 2000 }) .finally(() => { - this.loading.wipe = false - this.loading.all = false + this.idState.loading.wipe = false + this.idState.loading.all = false }) } }, @@ -428,36 +546,38 @@ export default { filterManagers(managers) { return managers.filter((manager) => manager.id !== this.user.id) }, + async initManager(userId) { await this.$store.dispatch('getUser', userId).then(response => { - this.currentManager = response?.data.ocs.data + this.idState.currentManager = response?.data.ocs.data }) }, + async searchUserManager(query) { await this.$store.dispatch('searchUsers', { offset: 0, limit: 10, search: query }).then(response => { const users = response?.data ? this.filterManagers(Object.values(response?.data.ocs.data.users)) : [] if (users.length > 0) { - this.possibleManagers = users + this.idState.possibleManagers = users } }) }, updateUserManager(manager) { if (manager === null) { - this.currentManager = '' + this.idState.currentManager = '' } - this.loading.manager = true + this.idState.loading.manager = true try { this.$store.dispatch('setUserData', { userid: this.user.id, key: 'manager', - value: this.currentManager ? this.currentManager.id : '', + value: this.idState.currentManager ? this.idState.currentManager.id : '', }) } catch (error) { showError(t('setting', 'Update of user manager was failed')) console.error(error) } finally { - this.loading.manager = false + this.idState.loading.manager = false } }, @@ -474,12 +594,12 @@ export default { }, (result) => { if (result) { - this.loading.delete = true - this.loading.all = true + this.idState.loading.delete = true + this.idState.loading.all = true return this.$store.dispatch('deleteUser', userid) .then(() => { - this.loading.delete = false - this.loading.all = false + this.idState.loading.delete = false + this.idState.loading.all = false }) } }, @@ -488,8 +608,8 @@ export default { }, enableDisableUser() { - this.loading.delete = true - this.loading.all = true + this.idState.loading.delete = true + this.idState.loading.all = true const userid = this.user.id const enabled = !this.user.enabled return this.$store.dispatch('enableDisableUser', { @@ -497,8 +617,8 @@ export default { enabled, }) .then(() => { - this.loading.delete = false - this.loading.all = false + this.idState.loading.delete = false + this.idState.loading.all = false }) }, @@ -508,14 +628,14 @@ export default { * @param {string} displayName The display name */ updateDisplayName() { - this.loading.displayName = true + this.idState.loading.displayName = true this.$store.dispatch('setUserData', { userid: this.user.id, key: 'displayname', - value: this.editedDisplayName, + value: this.idState.editedDisplayName, }).then(() => { - this.loading.displayName = false - if (this.editedDisplayName === this.user.displayname) { + this.idState.loading.displayName = false + if (this.idState.editedDisplayName === this.user.displayname) { showSuccess(t('setting', 'Display name was successfully changed')) } }) @@ -527,18 +647,18 @@ export default { * @param {string} password The email address */ updatePassword() { - this.loading.password = true - if (this.editedPassword.length === 0) { + this.idState.loading.password = true + if (this.idState.editedPassword.length === 0) { showError(t('setting', "Password can't be empty")) - this.loading.password = false + this.idState.loading.password = false } else { this.$store.dispatch('setUserData', { userid: this.user.id, key: 'password', - value: this.editedPassword, + value: this.idState.editedPassword, }).then(() => { - this.loading.password = false - this.editedPassword = '' + this.idState.loading.password = false + this.idState.editedPassword = '' showSuccess(t('setting', 'Password was successfully changed')) }) } @@ -550,19 +670,19 @@ export default { * @param {string} mailAddress The email address */ updateEmail() { - this.loading.mailAddress = true - if (this.editedMail === '') { + this.idState.loading.mailAddress = true + if (this.idState.editedMail === '') { showError(t('setting', "Email can't be empty")) - this.loading.mailAddress = false - this.editedMail = this.user.email + this.idState.loading.mailAddress = false + this.idState.editedMail = this.user.email } else { this.$store.dispatch('setUserData', { userid: this.user.id, key: 'email', - value: this.editedMail, + value: this.idState.editedMail, }).then(() => { - this.loading.mailAddress = false - if (this.editedMail === this.user.email) { + this.idState.loading.mailAddress = false + if (this.idState.editedMail === this.user.email) { showSuccess(t('setting', 'Email was successfully changed')) } }) @@ -575,7 +695,7 @@ export default { * @param {string} gid Group id */ async createGroup({ name: gid }) { - this.loading = { groups: true, subadmins: true } + this.idState.loading = { groups: true, subadmins: true } try { await this.$store.dispatch('addGroup', gid) const userid = this.user.id @@ -583,7 +703,7 @@ export default { } catch (error) { console.error(error) } finally { - this.loading = { groups: false, subadmins: false } + this.idState.loading = { groups: false, subadmins: false } } return this.$store.getters.getGroups[this.groups.length] }, @@ -599,7 +719,7 @@ export default { // Ignore return } - this.loading.groups = true + this.idState.loading.groups = true const userid = this.user.id const gid = group.id if (group.canAdd === false) { @@ -610,7 +730,7 @@ export default { } catch (error) { console.error(error) } finally { - this.loading.groups = false + this.idState.loading.groups = false } }, @@ -623,7 +743,7 @@ export default { if (group.canRemove === false) { return false } - this.loading.groups = true + this.idState.loading.groups = true const userid = this.user.id const gid = group.id try { @@ -631,13 +751,13 @@ export default { userid, gid, }) - this.loading.groups = false + this.idState.loading.groups = false // remove user from current list if current list is the removed group if (this.$route.params.selectedGroup === gid) { this.$store.commit('deleteUser', userid) } } catch { - this.loading.groups = false + this.idState.loading.groups = false } }, @@ -647,7 +767,7 @@ export default { * @param {object} group Group object */ async addUserSubAdmin(group) { - this.loading.subadmins = true + this.idState.loading.subadmins = true const userid = this.user.id const gid = group.id try { @@ -655,7 +775,7 @@ export default { userid, gid, }) - this.loading.subadmins = false + this.idState.loading.subadmins = false } catch (error) { console.error(error) } @@ -667,7 +787,7 @@ export default { * @param {object} group Group object */ async removeUserSubAdmin(group) { - this.loading.subadmins = true + this.idState.loading.subadmins = true const userid = this.user.id const gid = group.id @@ -679,7 +799,7 @@ export default { } catch (error) { console.error(error) } finally { - this.loading.subadmins = false + this.idState.loading.subadmins = false } }, @@ -692,9 +812,9 @@ export default { async setUserQuota(quota = 'none') { // Make sure correct label is set for unlimited quota if (quota === 'none') { - quota = this.unlimitedQuota + quota = unlimitedQuota } - this.loading.quota = true + this.idState.loading.quota = true // ensure we only send the preset id quota = quota.id ? quota.id : quota @@ -707,7 +827,7 @@ export default { } catch (error) { console.error(error) } finally { - this.loading.quota = false + this.idState.loading.quota = false } return quota }, @@ -725,7 +845,7 @@ export default { // only used for new presets sent through @Tag const validQuota = OC.Util.computerFileSize(quota) if (validQuota === null) { - return this.unlimitedQuota + return unlimitedQuota } else { // unify format output quota = OC.Util.humanFileSize(OC.Util.computerFileSize(quota)) @@ -740,7 +860,7 @@ export default { * @return {object} */ async setUserLanguage(lang) { - this.loading.languages = true + this.idState.loading.languages = true // ensure we only send the preset id try { await this.$store.dispatch('setUserData', { @@ -748,7 +868,7 @@ export default { key: 'language', value: lang.code, }) - this.loading.languages = false + this.idState.loading.languages = false } catch (error) { console.error(error) } @@ -759,48 +879,65 @@ export default { * Dispatch new welcome mail request */ sendWelcomeMail() { - this.loading.all = true + this.idState.loading.all = true this.$store.dispatch('sendWelcomeMail', this.user.id) .then(() => showSuccess(t('setting', 'Welcome mail sent!'), { timeout: 2000 })) .finally(() => { - this.loading.all = false + this.idState.loading.all = false }) }, - toggleEdit() { - this.editing = false - if (this.editedDisplayName !== this.user.displayname) { - this.editedDisplayName = this.user.displayname - } else if (this.editedMail !== this.user.email) { - this.editedMail = this.user.email + async toggleEdit() { + this.idState.editing = !this.idState.editing + if (this.idState.editing) { + await this.$nextTick() + this.$refs.displayNameField?.$refs?.inputField?.$refs?.input?.focus() + } + if (this.idState.editedDisplayName !== this.user.displayname) { + this.idState.editedDisplayName = this.user.displayname + } else if (this.idState.editedMail !== this.user.email) { + this.idState.editedMail = this.user.email ?? '' } }, }, } </script> -<style scoped lang="scss"> - // Force menu to be above other rows - .row--menu-opened { - z-index: 1 !important; - } - .row :deep() { - .v-select.select { - // reset min width to 100% instead of X px - min-width: 100%; - } +<style lang="scss" scoped> +@import './shared/styles.scss'; - .mailAddress, - .password, - .displayName { +.row { + @include cell; + + &__cell { + :deep { .input-field, + .input-field__main-wrapper, .input-field__input { - height: 48px!important; + height: 48px !important; } + .button-vue--icon-only { - height: 44px!important; + height: 44px !important; + } + + .v-select.select { + min-width: var(--cell-min-width); } } - } + } + &__progress { + margin-top: 4px; + + &--warn { + &::-moz-progress-bar { + background: var(--color-warning) !important; + } + &::-webkit-progress-value { + background: var(--color-warning) !important; + } + } + } +} </style> diff --git a/apps/settings/src/components/Users/UserRowActions.vue b/apps/settings/src/components/Users/UserRowActions.vue index ad89528fda7..4da5fd402fc 100644 --- a/apps/settings/src/components/Users/UserRowActions.vue +++ b/apps/settings/src/components/Users/UserRowActions.vue @@ -1,18 +1,44 @@ +<!-- + - @copyright 2023 Ferdinand Thiessen <opensource@fthiessen.de> + - + - @author Christopher Ng <chrng8@gmail.com> + - @author Ferdinand Thiessen <opensource@fthiessen.de> + - + - @license AGPL-3.0-or-later + - + - This program is free software: you can redistribute it and/or modify + - it under the terms of the GNU Affero General Public License as + - published by the Free Software Foundation, either version 3 of the + - License, or (at your option) any later version. + - + - This program is distributed in the hope that it will be useful, + - but WITHOUT ANY WARRANTY; without even the implied warranty of + - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + - GNU Affero General Public License for more details. + - + - You should have received a copy of the GNU Affero General Public License + - along with this program. If not, see <http://www.gnu.org/licenses/>. + - +--> + <template> <NcActions :aria-label="t('settings', 'Toggle user actions menu')" + :disabled="disabled" :inline="1"> - <NcActionButton @click="toggleEdit"> + <NcActionButton :disabled="disabled" + @click="toggleEdit"> {{ edit ? t('settings', 'Done') : t('settings', 'Edit') }} <template #icon> - <NcIconSvgWrapper :svg="editSvg" aria-hidden="true" /> + <NcIconSvgWrapper :key="editSvg" :svg="editSvg" aria-hidden="true" /> </template> </NcActionButton> - <NcActionButton v-for="(action, index) in actions" + <NcActionButton v-for="({ action, icon, text }, index) in actions" :key="index" - :aria-label="action.text" - :icon="action.icon" - @click="action.action"> - {{ action.text }} + :disabled="disabled" + :aria-label="text" + :icon="icon" + @click="action"> + {{ text }} </NcActionButton> </NcActions> </template> @@ -49,6 +75,14 @@ export default defineComponent({ }, /** + * The state whether the row is currently disabled + */ + disabled: { + type: Boolean, + required: true, + }, + + /** * The state whether the row is currently edited */ edit: { diff --git a/apps/settings/src/components/Users/UserRowSimple.vue b/apps/settings/src/components/Users/UserRowSimple.vue deleted file mode 100644 index 3d7f79b4195..00000000000 --- a/apps/settings/src/components/Users/UserRowSimple.vue +++ /dev/null @@ -1,185 +0,0 @@ -<template> - <div class="row" - :class="{'disabled': loading.delete || loading.disable}" - :data-id="user.id"> - <div class="avatar" :class="{'icon-loading-small': loading.delete || loading.disable || loading.wipe}"> - <img v-if="!loading.delete && !loading.disable && !loading.wipe" - alt="" - width="32" - height="32" - :src="generateAvatar(user.id, isDarkTheme)"> - </div> - <!-- dirty hack to ellipsis on two lines --> - <div class="name"> - <div class="displayName subtitle"> - <div :title="user.displayname.length > 20 ? user.displayname : ''" class="cellText"> - <strong> - {{ user.displayname }} - </strong> - </div> - </div> - {{ user.id }} - </div> - <div /> - <div class="mailAddress"> - <div :title="user.email !== null && user.email.length > 20 ? user.email : ''" class="cellText"> - {{ user.email }} - </div> - </div> - <div class="groups"> - {{ userGroupsLabels }} - </div> - <div v-if="subAdminsGroups.length > 0 && settings.isAdmin" class="subAdminsGroups"> - {{ userSubAdminsGroupsLabels }} - </div> - <div class="userQuota"> - <div class="quota"> - {{ userQuota }} ({{ usedSpace }}) - <progress class="quota-user-progress" - :class="{'warn': usedQuota > 80}" - :value="usedQuota" - max="100" /> - </div> - </div> - <div v-if="showConfig.showLanguages" class="languages"> - {{ userLanguage.name }} - </div> - <div v-if="showConfig.showUserBackend || showConfig.showStoragePath" class="userBackend"> - <div v-if="showConfig.showUserBackend" class="userBackend"> - {{ user.backend }} - </div> - <div v-if="showConfig.showStoragePath" :title="user.storageLocation" class="storageLocation subtitle"> - {{ user.storageLocation }} - </div> - </div> - <div v-if="showConfig.showLastLogin" :title="userLastLoginTooltip" class="lastLogin"> - {{ userLastLogin }} - </div> - <div class="managers"> - {{ user.manager }} - </div> - <div class="userActions"> - <UserRowActions v-if="canEdit && !loading.all" - :actions="userActions" - :edit="false" - @update:edit="toggleEdit" /> - </div> - </div> -</template> - -<script> -import { getCurrentUser } from '@nextcloud/auth' - -import ClickOutside from 'vue-click-outside' - -import UserRowActions from './UserRowActions.vue' -import UserRowMixin from '../../mixins/UserRowMixin.js' - -export default { - name: 'UserRowSimple', - components: { - UserRowActions, - }, - directives: { - ClickOutside, - }, - mixins: [UserRowMixin], - props: { - user: { - type: Object, - required: true, - }, - loading: { - type: Object, - required: true, - }, - showConfig: { - type: Object, - required: true, - }, - userActions: { - type: Array, - required: true, - }, - openedMenu: { - type: Boolean, - required: true, - }, - subAdminsGroups: { - type: Array, - required: true, - }, - settings: { - type: Object, - required: true, - }, - isDarkTheme: { - type: Boolean, - required: true, - }, - }, - computed: { - userGroupsLabels() { - return this.userGroups - .map(group => group.name) - .join(', ') - }, - userSubAdminsGroupsLabels() { - return this.userSubAdminsGroups - .map(group => group.name) - .join(', ') - }, - usedSpace() { - if (this.user.quota.used) { - return t('settings', '{size} used', { size: OC.Util.humanFileSize(this.user.quota.used) }) - } - return t('settings', '{size} used', { size: OC.Util.humanFileSize(0) }) - }, - canEdit() { - return getCurrentUser().uid !== this.user.id || this.settings.isAdmin - }, - userQuota() { - let quota = this.user.quota.quota - - if (quota === 'default') { - quota = this.settings.defaultQuota - if (quota !== 'none') { - // convert to numeric value to match what the server would usually return - quota = OC.Util.computerFileSize(quota) - } - } - - // when the default quota is unlimited, the server returns -3 here, map it to "none" - if (quota === 'none' || quota === -3) { - return t('settings', 'Unlimited') - } else if (quota >= 0) { - return OC.Util.humanFileSize(quota) - } - return OC.Util.humanFileSize(0) - }, - }, - methods: { - toggleMenu() { - this.$emit('update:openedMenu', !this.openedMenu) - }, - hideMenu() { - this.$emit('update:openedMenu', false) - }, - toggleEdit() { - this.$emit('update:editing', true) - }, - }, -} -</script> - -<style lang="scss"> - .cellText { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - .icon-more { - background-color: var(--color-main-background); - border: 0; - } -</style> diff --git a/apps/settings/src/components/Users/shared/styles.scss b/apps/settings/src/components/Users/shared/styles.scss new file mode 100644 index 00000000000..12525347738 --- /dev/null +++ b/apps/settings/src/components/Users/shared/styles.scss @@ -0,0 +1,110 @@ +/** + * @copyright 2023 Christopher Ng <chrng8@gmail.com> + * + * @author Christopher Ng <chrng8@gmail.com> + * + * @license AGPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + * + */ + +@mixin row { + position: absolute; + display: flex; + height: var(--row-height); + background-color: var(--color-main-background); +} + +@mixin cell { + &__cell { + display: flex; + flex-direction: column; + justify-content: center; + padding: 0 var(--cell-padding); + width: var(--cell-width); + color: var(--color-main-text); + + strong, + span, + label { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + overflow-wrap: anywhere; + } + + @media (min-width: 670px) { /* Show one &--large column between stickied columns */ + &--avatar, + &--displayname { + position: sticky; + z-index: 10; + background-color: var(--color-main-background); + } + + &--avatar { + left: 0; + } + + &--displayname { + left: var(--avatar-cell-width); + border-right: 1px solid var(--color-border); + } + } + + &--avatar { + width: var(--avatar-cell-width); + align-items: center; + padding: 0; + user-select: none; + } + + &--multiline { + span { + line-height: 1.3em; + white-space: unset; + + @supports (-webkit-line-clamp: 2) { + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + } + } + } + + &--large { + width: 300px; + } + + &--obfuscated { + width: 400px; + } + + &--actions { + position: sticky; + right: 0; + z-index: 10; + display: flex; + flex-direction: row; + align-items: center; + width: 110px; + background-color: var(--color-main-background); + border-left: 1px solid var(--color-border); + } + } + + &__subtitle { + color: var(--color-text-maxcontrast); + } +} diff --git a/apps/settings/src/mixins/UserRowMixin.js b/apps/settings/src/mixins/UserRowMixin.js index 23ed7a1ffab..f7e98c9a895 100644 --- a/apps/settings/src/mixins/UserRowMixin.js +++ b/apps/settings/src/mixins/UserRowMixin.js @@ -22,8 +22,6 @@ * */ -import { generateUrl } from '@nextcloud/router' - export default { props: { user: { @@ -46,10 +44,6 @@ export default { type: Array, default: () => [], }, - showConfig: { - type: Object, - default: () => ({}), - }, languages: { type: Array, required: true, @@ -60,6 +54,10 @@ export default { }, }, computed: { + showConfig() { + return this.$store.getters.getShowConfig + }, + /* GROUPS MANAGEMENT */ userGroups() { const userGroups = this.groups.filter(group => this.user.groups.includes(group.id)) @@ -153,32 +151,4 @@ export default { return t('settings', 'Never') }, }, - methods: { - /** - * Generate avatar url - * - * @param {string} user The user name - * @param {bool} isDarkTheme Whether the avatar should be the dark version - * @return {string} - */ - generateAvatar(user, isDarkTheme) { - if (isDarkTheme) { - return generateUrl( - '/avatar/{user}/64/dark?v={version}', - { - user, - version: oc_userconfig.avatar.version, - } - ) - } else { - return generateUrl( - '/avatar/{user}/64?v={version}', - { - user, - version: oc_userconfig.avatar.version, - } - ) - } - }, - }, } diff --git a/apps/settings/src/store/users.js b/apps/settings/src/store/users.js index ab8105ecb51..b4af9851218 100644 --- a/apps/settings/src/store/users.js +++ b/apps/settings/src/store/users.js @@ -62,12 +62,22 @@ const state = { usersOffset: 0, usersLimit: 25, userCount: 0, + showConfig: { + showStoragePath: false, + showUserBackend: false, + showLastLogin: false, + showNewUserForm: false, + showLanguages: false, + }, } const mutations = { appendUsers(state, usersObj) { - // convert obj to array - const users = state.users.concat(Object.keys(usersObj).map(userid => usersObj[userid])) + const existingUsers = state.users.map(({ id }) => id) + const newUsers = Object.values(usersObj) + .filter(({ id }) => !existingUsers.includes(id)) + + const users = state.users.concat(newUsers) state.usersOffset += state.usersLimit state.users = users }, @@ -149,7 +159,7 @@ const mutations = { }, addUserData(state, response) { const user = response.data.ocs.data - state.users.push(user) + state.users.unshift(user) this.commit('updateUserCounts', { user, actionType: 'create' }) }, enableDisableUser(state, { userid, enabled }) { @@ -221,6 +231,10 @@ const mutations = { state.users = [] state.usersOffset = 0 }, + + setShowConfig(state, { key, value }) { + state.showConfig[key] = value + }, } const getters = { @@ -246,6 +260,9 @@ const getters = { getUserCount(state) { return state.userCount }, + getShowConfig(state) { + return state.showConfig + }, } const CancelToken = axios.CancelToken diff --git a/apps/settings/src/utils/userUtils.ts b/apps/settings/src/utils/userUtils.ts new file mode 100644 index 00000000000..eff8315b693 --- /dev/null +++ b/apps/settings/src/utils/userUtils.ts @@ -0,0 +1,40 @@ +/** + * @copyright 2023 Christopher Ng <chrng8@gmail.com> + * + * @author Christopher Ng <chrng8@gmail.com> + * + * @license AGPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + * + */ + +export const unlimitedQuota = { + id: 'none', + label: t('settings', 'Unlimited'), +} + +export const defaultQuota = { + id: 'default', + label: t('settings', 'Default quota'), +} + +/** + * Return `true` if the logged in user does not have permissions to view the + * data of `user` + */ +export const isObfuscated = (user: { id: string, [key: string]: any }) => { + const keys = Object.keys(user) + return keys.length === 1 && keys.at(0) === 'id' +} diff --git a/apps/settings/src/views/Users.vue b/apps/settings/src/views/Users.vue index 6f8318a872a..e1f26ea6ea3 100644 --- a/apps/settings/src/views/Users.vue +++ b/apps/settings/src/views/Users.vue @@ -129,9 +129,7 @@ </template> </NcAppNavigation> <NcAppContent> - <UserList :users="users" - :show-config="showConfig" - :selected-group="selectedGroupDecoded" + <UserList :selected-group="selectedGroupDecoded" :external-actions="externalActions" /> </NcAppContent> </NcContent> @@ -160,6 +158,7 @@ import { generateUrl } from '@nextcloud/router' import GroupListItem from '../components/GroupListItem.vue' import UserList from '../components/UserList.vue' +import { unlimitedQuota } from '../utils/userUtils.ts' Vue.use(VueLocalStorage) @@ -189,23 +188,17 @@ export default { }, data() { return { - // default quota is set to unlimited - unlimitedQuota: { id: 'none', label: t('settings', 'Unlimited') }, // temporary value used for multiselect change selectedQuota: false, externalActions: [], loadingAddGroup: false, loadingSendMail: false, - showConfig: { - showStoragePath: false, - showUserBackend: false, - showLastLogin: false, - showNewUserForm: false, - showLanguages: false, - }, } }, computed: { + showConfig() { + return this.$store.getters.getShowConfig + }, selectedGroupDecoded() { return this.selectedGroup ? decodeURIComponent(this.selectedGroup) : null }, @@ -224,25 +217,33 @@ export default { // Local settings showLanguages: { - get() { return this.getLocalstorage('showLanguages') }, + get() { + return this.getLocalstorage('showLanguages') + }, set(status) { this.setLocalStorage('showLanguages', status) }, }, showLastLogin: { - get() { return this.getLocalstorage('showLastLogin') }, + get() { + return this.getLocalstorage('showLastLogin') + }, set(status) { this.setLocalStorage('showLastLogin', status) }, }, showUserBackend: { - get() { return this.getLocalstorage('showUserBackend') }, + get() { + return this.getLocalstorage('showUserBackend') + }, set(status) { this.setLocalStorage('showUserBackend', status) }, }, showStoragePath: { - get() { return this.getLocalstorage('showStoragePath') }, + get() { + return this.getLocalstorage('showStoragePath') + }, set(status) { this.setLocalStorage('showStoragePath', status) }, @@ -261,7 +262,7 @@ export default { const quotaPreset = this.settings.quotaPreset.reduce((acc, cur) => acc.concat({ id: cur, label: cur }), []) // add default presets if (this.settings.allowUnlimitedQuota) { - quotaPreset.unshift(this.unlimitedQuota) + quotaPreset.unshift(unlimitedQuota) } return quotaPreset }, @@ -271,11 +272,11 @@ export default { if (this.selectedQuota !== false) { return this.selectedQuota } - if (this.settings.defaultQuota !== this.unlimitedQuota.id && OC.Util.computerFileSize(this.settings.defaultQuota) >= 0) { + if (this.settings.defaultQuota !== unlimitedQuota.id && OC.Util.computerFileSize(this.settings.defaultQuota) >= 0) { // if value is valid, let's map the quotaOptions or return custom quota return { id: this.settings.defaultQuota, label: this.settings.defaultQuota } } - return this.unlimitedQuota // unlimited + return unlimitedQuota // unlimited }, set(quota) { this.selectedQuota = quota @@ -340,17 +341,20 @@ export default { }, methods: { showNewUserMenu() { - this.showConfig.showNewUserForm = true + this.$store.commit('setShowConfig', { + key: 'showNewUserForm', + value: true, + }) }, getLocalstorage(key) { // force initialization const localConfig = this.$localStorage.get(key) // if localstorage is null, fallback to original values - this.showConfig[key] = localConfig !== null ? localConfig === 'true' : this.showConfig[key] + this.$store.commit('setShowConfig', { key, value: localConfig !== null ? localConfig === 'true' : this.showConfig[key] }) return this.showConfig[key] }, setLocalStorage(key, status) { - this.showConfig[key] = status + this.$store.commit('setShowConfig', { key, value: status }) this.$localStorage.set(key, status) return status }, @@ -363,7 +367,7 @@ export default { setDefaultQuota(quota = 'none') { // Make sure correct label is set for unlimited quota if (quota === 'none') { - quota = this.unlimitedQuota + quota = unlimitedQuota } this.$store.dispatch('setAppConfig', { app: 'files', @@ -391,7 +395,7 @@ export default { // only used for new presets sent through @Tag const validQuota = OC.Util.computerFileSize(quota) if (validQuota === null) { - return this.unlimitedQuota + return unlimitedQuota } else { // unify format output quota = OC.Util.humanFileSize(OC.Util.computerFileSize(quota)) @@ -485,6 +489,14 @@ export default { </script> <style lang="scss" scoped> +.app-content { + // Virtual list needs to be full height and is scrollable + display: flex; + overflow: hidden; + flex-direction: column; + max-height: 100%; +} + // force hiding the editing action for the add group entry .app-navigation__list #addgroup::v-deep .app-navigation-entry__utils { display: none; |