aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorChristopher Ng <chrng8@gmail.com>2023-07-07 11:31:23 -0700
committerChristopher Ng <chrng8@gmail.com>2023-07-12 17:30:11 -0700
commitcbfe0c67e9072f18bb40b795032d47f1639decb9 (patch)
tree8090c18f58dd0f4794f0265907c5edc218af44df
parent97a93c73cec09a72cf035e9f70a62d4396b09e82 (diff)
downloadnextcloud-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.map2
-rw-r--r--apps/settings/css/settings.scss278
-rw-r--r--apps/settings/src/components/UserList.vue382
-rw-r--r--apps/settings/src/components/Users/NewUserModal.vue32
-rw-r--r--apps/settings/src/components/Users/UserListFooter.vue126
-rw-r--r--apps/settings/src/components/Users/UserListHeader.vue150
-rw-r--r--apps/settings/src/components/Users/UserRow.vue785
-rw-r--r--apps/settings/src/components/Users/UserRowActions.vue48
-rw-r--r--apps/settings/src/components/Users/UserRowSimple.vue185
-rw-r--r--apps/settings/src/components/Users/shared/styles.scss110
-rw-r--r--apps/settings/src/mixins/UserRowMixin.js38
-rw-r--r--apps/settings/src/store/users.js23
-rw-r--r--apps/settings/src/utils/userUtils.ts40
-rw-r--r--apps/settings/src/views/Users.vue60
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;